Skip to content

[5.x] Add support for whereHas() and whereRelation() to entry and user 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

Open
wants to merge 24 commits into
base: 5.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7068857
Support whereHas / whereRelation in Entry and User query builders
ryanmitchell Jul 20, 2023
aef4da1
Better comments
ryanmitchell Jul 20, 2023
eb3c22f
Fix return type
ryanmitchell Jul 20, 2023
cf20ade
Don't need these here anymore
ryanmitchell Jul 20, 2023
3ea9f36
Add some test coverage
ryanmitchell Jul 21, 2023
1913bd3
Support basic relation check when maxItems: 1
ryanmitchell Jul 21, 2023
b8cba41
:beer:
ryanmitchell Jul 21, 2023
e6b82b6
Move count check and rewrite error message
ryanmitchell Jul 21, 2023
86ed319
Merge branch '4.x' into feature/where-has-relationships
ryanmitchell Aug 8, 2023
86c1dcc
Don't need boolean here
ryanmitchell Aug 8, 2023
3986056
Support taxonomies for @robdekort
ryanmitchell Oct 5, 2023
2522fbf
Merge branch '4.x' into feature/where-has-relationships
ryanmitchell Oct 5, 2023
3c94835
Tidy up
ryanmitchell Oct 5, 2023
20bd209
Merge branch '4.x' into feature/where-has-relationships
ryanmitchell Oct 12, 2023
dadcca7
Merge branch '4.x' into feature/where-has-relationships
ryanmitchell Dec 12, 2023
3e81f42
:beer:
ryanmitchell Dec 12, 2023
ce0c244
Merge branch '4.x' into feature/where-has-relationships
ryanmitchell Jan 10, 2024
b63a327
Merge branch '4.x' into feature/where-has-relationships
ryanmitchell Feb 29, 2024
2167bc0
:beer:
ryanmitchell Feb 29, 2024
8ff6044
Merge branch '5.x' into feature/where-has-relationships
ryanmitchell May 10, 2024
0d39b65
ids have changed in tests
ryanmitchell May 10, 2024
4e933e6
Merge branch '5.x' into feature/where-has-relationships
ryanmitchell Jul 23, 2024
0f55edc
:beer:
ryanmitchell Jul 23, 2024
bb94e89
Merge branch '5.x' into feature/where-has-relationships
ryanmitchell Oct 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Fields/Fieldtype.php
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,11 @@ public function isRelationship(): bool
return $this->relationship;
}

public function relationshipQueryBuilder()
{
return false;
}

public function toQueryableValue($value)
{
return $value;
Expand Down
8 changes: 8 additions & 0 deletions src/Fieldtypes/Entries.php
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,14 @@ protected function getItemsForPreProcessIndex($values): SupportCollection
: $augmented->whereAnyStatus()->get();
}

public function relationshipQueryBuilder()
{
$collections = $this->config('collections');

return Entry::query()
->when($collections, fn ($query) => $query->whereIn('collection', $collections));
}

public function filter()
{
return new EntriesFilter($this);
Expand Down
8 changes: 8 additions & 0 deletions src/Fieldtypes/Terms.php
Original file line number Diff line number Diff line change
Expand Up @@ -446,4 +446,12 @@ protected function getItemsForPreProcessIndex($values): Collection

return $this->config('max_items') === 1 ? collect([$augmented]) : $augmented->get();
}

public function relationshipQueryBuilder()
{
$taxonomies = $this->taxonomies();

return Term::query()
->when($taxonomies, fn ($query) => $query->whereIn('taxonomy', $taxonomies));
}
}
5 changes: 5 additions & 0 deletions src/Fieldtypes/Users.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,4 +217,9 @@ public function filter()
{
return new UserFilter($this);
}

public function relationshipQueryBuilder()
{
return User::query();
}
}
2 changes: 2 additions & 0 deletions src/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

abstract class Builder implements Contract
{
use Traits\QueriesRelationships;

protected $columns;
protected $limit;
protected $offset = 0;
Expand Down
244 changes: 244 additions & 0 deletions src/Query/Traits/QueriesRelationships.php
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];
}
}
25 changes: 25 additions & 0 deletions src/Stache/Query/EntryQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,29 @@ protected function getWhereColumnKeyValuesByIndex($column)
return $this->getWhereColumnKeysFromStore($collection, ['column' => $column]);
});
}

protected function getBlueprintsForRelations()
{
$wheres = collect($this->wheres);

$collections = $wheres->where('column', 'collection')
->flatMap(function ($where) {
return $where['values'] ?? [$where['value']] ?? [];
})
->unique();

if (! $collections->count()) {
$collections = Facades\Collection::all();
}

return $collections->flatMap(function ($collection) {
if (is_string($collection)) {
$collection = Facades\Collection::find($collection);
}

return $collection ? $collection->entryBlueprints() : false;
})
->filter()
->unique();
}
}
17 changes: 17 additions & 0 deletions src/Stache/Query/TermQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,21 @@ protected function getWhereColumnKeyValuesByIndex($column)

return $items;
}

protected function getBlueprintsForRelations()
{
$taxonomies = empty($this->taxonomies)
? Facades\Taxonomy::handles()
: $this->taxonomies;

return $taxonomies->flatMap(function ($taxonomy) {
if (is_string($taxonomy)) {
$taxonomy = Facades\Taxonomy::find($taxonomy);
}

return $taxonomy ? $taxonomy->termBlueprints() : false;
})
->filter()
->unique();
}
}
6 changes: 6 additions & 0 deletions src/Stache/Query/UserQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Statamic\Stache\Query;

use Statamic\Auth\UserCollection;
use Statamic\Facades\User;

class UserQueryBuilder extends Builder
{
Expand Down Expand Up @@ -50,4 +51,9 @@ protected function getOrderKeyValuesByIndex()
return [$orderBy->sort => $items];
});
}

protected function getBlueprintsForRelations()
{
return collect([User::make()->blueprint()]);
}
}
Loading