-
-
Notifications
You must be signed in to change notification settings - Fork 590
[5.x] Add support for whereHas() etc to query builders
#8476
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jasonvarga
merged 30 commits into
statamic:5.x
from
ryanmitchell:feature/where-has-relationships
Oct 16, 2025
Merged
Changes from 12 commits
Commits
Show all changes
30 commits
Select commit
Hold shift + click to select a range
7068857
Support whereHas / whereRelation in Entry and User query builders
ryanmitchell aef4da1
Better comments
ryanmitchell eb3c22f
Fix return type
ryanmitchell cf20ade
Don't need these here anymore
ryanmitchell 3ea9f36
Add some test coverage
ryanmitchell 1913bd3
Support basic relation check when maxItems: 1
ryanmitchell b8cba41
:beer:
ryanmitchell e6b82b6
Move count check and rewrite error message
ryanmitchell 86ed319
Merge branch '4.x' into feature/where-has-relationships
ryanmitchell 86c1dcc
Don't need boolean here
ryanmitchell 3986056
Support taxonomies for @robdekort
ryanmitchell 2522fbf
Merge branch '4.x' into feature/where-has-relationships
ryanmitchell 3c94835
Tidy up
ryanmitchell 20bd209
Merge branch '4.x' into feature/where-has-relationships
ryanmitchell dadcca7
Merge branch '4.x' into feature/where-has-relationships
ryanmitchell 3e81f42
:beer:
ryanmitchell ce0c244
Merge branch '4.x' into feature/where-has-relationships
ryanmitchell b63a327
Merge branch '4.x' into feature/where-has-relationships
ryanmitchell 2167bc0
:beer:
ryanmitchell 8ff6044
Merge branch '5.x' into feature/where-has-relationships
ryanmitchell 0d39b65
ids have changed in tests
ryanmitchell 4e933e6
Merge branch '5.x' into feature/where-has-relationships
ryanmitchell 0f55edc
:beer:
ryanmitchell bb94e89
Merge branch '5.x' into feature/where-has-relationships
ryanmitchell 2032e01
Merge branch '5.x' into feature/where-has-relationships
jasonvarga 5e8e1c8
move to concerns
jasonvarga 9a22e09
Don't support nested relationships
jasonvarga 5db4fa9
Handle taxonomy term fields with multiple taxonomies configured
jasonvarga c822d40
Merge branch '5.x' into feature/where-has-relationships
jasonvarga a1fa101
specify a single taxonomy in the test
jasonvarga File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,244 @@ | ||
| <?php | ||
|
|
||
| namespace Statamic\Query\Traits; | ||
|
|
||
| use Closure; | ||
| use InvalidArgumentException; | ||
| use Statamic\Support\Str; | ||
|
|
||
| trait QueriesRelationships | ||
| { | ||
| /** | ||
| * Add a relationship count / exists condition to the query. | ||
| * | ||
| * @param string $relation | ||
| * @param string $operator | ||
| * @param int $count | ||
| * @param string $boolean | ||
| * @return \Statamic\Query\Builder|static | ||
| * | ||
| * @throws \InvalidArgumentException | ||
| */ | ||
| public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', Closure $callback = null) | ||
| { | ||
| [$relationQueryBuilder, $relationField] = $this->getRelationQueryBuilderAndField($relation); | ||
|
|
||
| $maxItems = $relationField->config()['max_items'] ?? 0; | ||
| $negate = in_array($operator, ['!=', '<']); | ||
|
|
||
| if (! $callback) { | ||
| if ($maxItems == 1) { | ||
| $method = $boolean == 'and' ? 'whereNull' : 'orWhereNull'; | ||
| if (! $negate) { | ||
| $method = str_replace('Null', 'NotNull', $method); | ||
| } | ||
|
|
||
| return $this->$method($relation); | ||
| } | ||
|
|
||
| return $this->{$boolean == 'and' ? 'whereJsonLength' : 'orWhereJsonLength'}($relation, $operator, $count); | ||
| } | ||
|
|
||
| if ($count != 1) { | ||
| throw new InvalidArgumentException('Counting with subqueries in has clauses is not supported'); | ||
| } | ||
|
|
||
| $ids = $relationQueryBuilder | ||
| ->where($callback) | ||
| ->get(['id']) | ||
| ->map(fn ($item) => Str::after($item->id(), '::')) | ||
| ->all(); | ||
|
|
||
| if ($maxItems == 1) { | ||
| $method = $boolean == 'and' ? 'whereIn' : 'orWhereIn'; | ||
| if ($negate) { | ||
| $method = str_replace('here', 'hereNot', $method); | ||
| } | ||
|
|
||
| return $this->$method($relation, $ids); | ||
| } | ||
|
|
||
| if (empty($ids)) { | ||
| return $this->{$boolean == 'and' ? 'whereJsonContains' : 'orWhereJsonContains'}($relation, ['']); | ||
| } | ||
|
|
||
| return $this->{$boolean == 'and' ? 'where' : 'orWhere'}(function ($subquery) use ($ids, $negate, $relation) { | ||
| foreach ($ids as $count => $id) { | ||
| $method = $count == 0 ? 'whereJsonContains' : 'orWhereJsonContains'; | ||
| if ($negate) { | ||
| $method = str_replace('Contains', 'DoesntContain', $method); | ||
| } | ||
|
|
||
| $subquery->$method($relation, [$id]); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Add a relationship count / exists condition to the query with an "or". | ||
| * | ||
| * @param string $relation | ||
| * @param string $operator | ||
| * @param int $count | ||
| * @return \Statamic\Query\Builder|static | ||
| */ | ||
| public function orHas($relation, $operator = '>=', $count = 1) | ||
| { | ||
| return $this->has($relation, $operator, $count, 'or'); | ||
| } | ||
|
|
||
| /** | ||
| * Add a relationship count / exists condition to the query. | ||
| * | ||
| * @param string $relation | ||
| * @param string $boolean | ||
| * @return \Statamic\Query\Builder|static | ||
| */ | ||
| public function doesntHave($relation, $boolean = 'and', Closure $callback = null) | ||
| { | ||
| return $this->has($relation, '<', 1, $boolean, $callback); | ||
| } | ||
|
|
||
| /** | ||
| * Add a relationship count / exists condition to the query with an "or". | ||
| * | ||
| * @param string $relation | ||
| * @return \Statamic\Query\Builder|static | ||
| */ | ||
| public function orDoesntHave($relation) | ||
| { | ||
| return $this->doesntHave($relation, 'or'); | ||
| } | ||
|
|
||
| /** | ||
| * Add a relationship count / exists condition to the query with where clauses. | ||
| * | ||
| * @param string $relation | ||
| * @param string $operator | ||
| * @param int $count | ||
| * @return \Statamic\Query\Builder|static | ||
| */ | ||
| public function whereHas($relation, Closure $callback = null, $operator = '>=', $count = 1) | ||
| { | ||
| return $this->has($relation, $operator, $count, 'and', $callback); | ||
| } | ||
|
|
||
| /** | ||
| * Add a relationship count / exists condition to the query with where clauses and an "or". | ||
| * | ||
| * @param string $relation | ||
| * @param string $operator | ||
| * @param int $count | ||
| * @return \Statamic\Query\Builder|static | ||
| */ | ||
| public function orWhereHas($relation, Closure $callback = null, $operator = '>=', $count = 1) | ||
| { | ||
| return $this->has($relation, $operator, $count, 'or', $callback); | ||
| } | ||
|
|
||
| /** | ||
| * Add a relationship count / exists condition to the query with where clauses. | ||
| * | ||
| * @param string $relation | ||
| * @return \Statamic\Query\Builder|static | ||
| */ | ||
| public function whereDoesntHave($relation, Closure $callback = null) | ||
| { | ||
| return $this->doesntHave($relation, 'and', $callback); | ||
| } | ||
|
|
||
| /** | ||
| * Add a relationship count / exists condition to the query with where clauses and an "or". | ||
| * | ||
| * @param string $relation | ||
| * @return \Statamic\Query\Builder|static | ||
| */ | ||
| public function orWhereDoesntHave($relation, Closure $callback = null) | ||
| { | ||
| return $this->doesntHave($relation, 'or', $callback); | ||
| } | ||
|
|
||
| /** | ||
| * Add a basic where clause to a relationship query. | ||
| * | ||
| * @param string $relation | ||
| * @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column | ||
| * @param mixed $operator | ||
| * @param mixed $value | ||
| * @return \Statamic\Query\Builder|static | ||
| */ | ||
| public function whereRelation($relation, $column, $operator = null, $value = null) | ||
| { | ||
| return $this->whereHas($relation, function ($query) use ($column, $operator, $value) { | ||
| if ($column instanceof Closure) { | ||
| $column($query); | ||
| } else { | ||
| $query->where($column, $operator, $value); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Add an "or where" clause to a relationship query. | ||
| * | ||
| * @param string $relation | ||
| * @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column | ||
| * @param mixed $operator | ||
| * @param mixed $value | ||
| * @return \Statamic\Query\Builder|static | ||
| */ | ||
| public function orWhereRelation($relation, $column, $operator = null, $value = null) | ||
| { | ||
| return $this->orWhereHas($relation, function ($query) use ($column, $operator, $value) { | ||
| if ($column instanceof Closure) { | ||
| $column($query); | ||
| } else { | ||
| $query->where($column, $operator, $value); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Get the blueprints available to this query builder | ||
| * | ||
| * @return \Illuminate\Support\Collection | ||
| */ | ||
| protected function getBlueprintsForRelations() | ||
| { | ||
| return collect(); | ||
| } | ||
|
|
||
| /** | ||
| * Get the query builder and field for the relation we are querying (if they exist) | ||
| * | ||
| * @param string $relation | ||
| * @return \Statamic\Query\Builder | ||
| */ | ||
| protected function getRelationQueryBuilderAndField($relation) | ||
| { | ||
| $relationField = $this->getBlueprintsForRelations() | ||
| ->flatMap(function ($blueprint) use ($relation) { | ||
| return $blueprint->fields()->all()->map(function ($field) use ($relation) { | ||
| if ($field->handle() == $relation && $field->fieldtype()->isRelationship()) { | ||
| return $field; | ||
| } | ||
| }) | ||
| ->filter() | ||
| ->values(); | ||
| }) | ||
| ->filter() | ||
| ->first(); | ||
|
|
||
| if (! $relationField) { | ||
| throw new InvalidArgumentException("Relation {$relation} does not exist"); | ||
| } | ||
|
|
||
| $queryBuilder = $relationField->fieldtype()->relationshipQueryBuilder(); | ||
|
|
||
| if (! $queryBuilder) { | ||
| throw new InvalidArgumentException("Relation {$relation} does not support subquerying"); | ||
| } | ||
|
|
||
| return [$queryBuilder, $relationField]; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.