Skip to content

Commit a8539b9

Browse files
[5.x] Add support for whereHas() etc to query builders (statamic#8476)
Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent 47db4dd commit a8539b9

File tree

12 files changed

+616
-1
lines changed

12 files changed

+616
-1
lines changed

src/Fields/Fieldtype.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,16 @@ public function isRelationship(): bool
381381
return $this->relationship;
382382
}
383383

384+
public function relationshipQueryBuilder()
385+
{
386+
return false;
387+
}
388+
389+
public function relationshipQueryIdMapFn(): ?\Closure
390+
{
391+
return null;
392+
}
393+
384394
public function toQueryableValue($value)
385395
{
386396
return $value;

src/Fieldtypes/Entries.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,14 @@ protected function getItemsForPreProcessIndex($values): SupportCollection
458458
return $this->queryBuilder($values)->whereAnyStatus()->get();
459459
}
460460

461+
public function relationshipQueryBuilder()
462+
{
463+
$collections = $this->config('collections');
464+
465+
return Entry::query()
466+
->when($collections, fn ($query) => $query->whereIn('collection', $collections));
467+
}
468+
461469
public function filter()
462470
{
463471
return new EntriesFilter($this);

src/Fieldtypes/Terms.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,21 @@ protected function getItemsForPreProcessIndex($values): Collection
486486
return $this->config('max_items') === 1 ? collect([$augmented]) : $augmented->get();
487487
}
488488

489+
public function relationshipQueryBuilder()
490+
{
491+
$taxonomies = $this->taxonomies();
492+
493+
return Term::query()
494+
->when($taxonomies, fn ($query) => $query->whereIn('taxonomy', $taxonomies));
495+
}
496+
497+
public function relationshipQueryIdMapFn(): ?\Closure
498+
{
499+
return $this->usingSingleTaxonomy()
500+
? fn ($term) => Str::after($term->id(), '::')
501+
: null;
502+
}
503+
489504
public function getItemHint($item): ?string
490505
{
491506
return collect([

src/Fieldtypes/Users.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,4 +229,9 @@ public function filter()
229229
{
230230
return new UserFilter($this);
231231
}
232+
233+
public function relationshipQueryBuilder()
234+
{
235+
return User::query();
236+
}
232237
}

src/Query/Builder.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@
1414
use Statamic\Extensions\Pagination\LengthAwarePaginator;
1515
use Statamic\Facades\Pattern;
1616
use Statamic\Query\Concerns\FakesQueries;
17+
use Statamic\Query\Concerns\QueriesRelationships;
1718
use Statamic\Query\Exceptions\MultipleRecordsFoundException;
1819
use Statamic\Query\Exceptions\RecordsNotFoundException;
1920
use Statamic\Query\Scopes\AppliesScopes;
2021

2122
abstract class Builder implements Contract
2223
{
23-
use AppliesScopes, FakesQueries;
24+
use AppliesScopes, FakesQueries, QueriesRelationships;
2425

2526
protected $columns;
2627
protected $limit;
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
<?php
2+
3+
namespace Statamic\Query\Concerns;
4+
5+
use Closure;
6+
use InvalidArgumentException;
7+
8+
trait QueriesRelationships
9+
{
10+
/**
11+
* Add a relationship count / exists condition to the query.
12+
*
13+
* @param string $relation
14+
* @param string $operator
15+
* @param int $count
16+
* @param string $boolean
17+
* @return \Statamic\Query\Builder|static
18+
*
19+
* @throws \InvalidArgumentException
20+
*/
21+
public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null)
22+
{
23+
if (str_contains($relation, '.')) {
24+
throw new InvalidArgumentException('Nested relations are not supported');
25+
}
26+
27+
[$relationQueryBuilder, $relationField] = $this->getRelationQueryBuilderAndField($relation);
28+
29+
$maxItems = $relationField->config()['max_items'] ?? 0;
30+
$negate = in_array($operator, ['!=', '<']);
31+
32+
if (! $callback) {
33+
if ($maxItems == 1) {
34+
$method = $boolean == 'and' ? 'whereNull' : 'orWhereNull';
35+
if (! $negate) {
36+
$method = str_replace('Null', 'NotNull', $method);
37+
}
38+
39+
return $this->$method($relation);
40+
}
41+
42+
return $this->{$boolean == 'and' ? 'whereJsonLength' : 'orWhereJsonLength'}($relation, $operator, $count);
43+
}
44+
45+
if ($count != 1) {
46+
throw new InvalidArgumentException('Counting with subqueries in has clauses is not supported');
47+
}
48+
49+
// Get the "IDs" - but really it's the values that are stored in the content.
50+
// In some cases, like taxonomy term fields, the values saved to the content
51+
// are not the actual IDs. e.g. term slugs will get saved when the field
52+
// is only configured with a single taxonomy.
53+
$idMapFn = $relationField->fieldtype()->relationshipQueryIdMapFn() ?? fn ($item) => $item->id();
54+
55+
$ids = $relationQueryBuilder
56+
->where($callback)
57+
->get(['id'])
58+
->map($idMapFn)
59+
->all();
60+
61+
if ($maxItems == 1) {
62+
$method = $boolean == 'and' ? 'whereIn' : 'orWhereIn';
63+
if ($negate) {
64+
$method = str_replace('here', 'hereNot', $method);
65+
}
66+
67+
return $this->$method($relation, $ids);
68+
}
69+
70+
if (empty($ids)) {
71+
return $this->{$boolean == 'and' ? 'whereJsonContains' : 'orWhereJsonContains'}($relation, ['']);
72+
}
73+
74+
return $this->{$boolean == 'and' ? 'where' : 'orWhere'}(function ($subquery) use ($ids, $negate, $relation) {
75+
foreach ($ids as $count => $id) {
76+
$method = $count == 0 ? 'whereJsonContains' : 'orWhereJsonContains';
77+
if ($negate) {
78+
$method = str_replace('Contains', 'DoesntContain', $method);
79+
}
80+
81+
$subquery->$method($relation, [$id]);
82+
}
83+
});
84+
}
85+
86+
/**
87+
* Add a relationship count / exists condition to the query with an "or".
88+
*
89+
* @param string $relation
90+
* @param string $operator
91+
* @param int $count
92+
* @return \Statamic\Query\Builder|static
93+
*/
94+
public function orHas($relation, $operator = '>=', $count = 1)
95+
{
96+
return $this->has($relation, $operator, $count, 'or');
97+
}
98+
99+
/**
100+
* Add a relationship count / exists condition to the query.
101+
*
102+
* @param string $relation
103+
* @param string $boolean
104+
* @return \Statamic\Query\Builder|static
105+
*/
106+
public function doesntHave($relation, $boolean = 'and', ?Closure $callback = null)
107+
{
108+
return $this->has($relation, '<', 1, $boolean, $callback);
109+
}
110+
111+
/**
112+
* Add a relationship count / exists condition to the query with an "or".
113+
*
114+
* @param string $relation
115+
* @return \Statamic\Query\Builder|static
116+
*/
117+
public function orDoesntHave($relation)
118+
{
119+
return $this->doesntHave($relation, 'or');
120+
}
121+
122+
/**
123+
* Add a relationship count / exists condition to the query with where clauses.
124+
*
125+
* @param string $relation
126+
* @param string $operator
127+
* @param int $count
128+
* @return \Statamic\Query\Builder|static
129+
*/
130+
public function whereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1)
131+
{
132+
return $this->has($relation, $operator, $count, 'and', $callback);
133+
}
134+
135+
/**
136+
* Add a relationship count / exists condition to the query with where clauses and an "or".
137+
*
138+
* @param string $relation
139+
* @param string $operator
140+
* @param int $count
141+
* @return \Statamic\Query\Builder|static
142+
*/
143+
public function orWhereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1)
144+
{
145+
return $this->has($relation, $operator, $count, 'or', $callback);
146+
}
147+
148+
/**
149+
* Add a relationship count / exists condition to the query with where clauses.
150+
*
151+
* @param string $relation
152+
* @return \Statamic\Query\Builder|static
153+
*/
154+
public function whereDoesntHave($relation, ?Closure $callback = null)
155+
{
156+
return $this->doesntHave($relation, 'and', $callback);
157+
}
158+
159+
/**
160+
* Add a relationship count / exists condition to the query with where clauses and an "or".
161+
*
162+
* @param string $relation
163+
* @return \Statamic\Query\Builder|static
164+
*/
165+
public function orWhereDoesntHave($relation, ?Closure $callback = null)
166+
{
167+
return $this->doesntHave($relation, 'or', $callback);
168+
}
169+
170+
/**
171+
* Add a basic where clause to a relationship query.
172+
*
173+
* @param string $relation
174+
* @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column
175+
* @param mixed $operator
176+
* @param mixed $value
177+
* @return \Statamic\Query\Builder|static
178+
*/
179+
public function whereRelation($relation, $column, $operator = null, $value = null)
180+
{
181+
return $this->whereHas($relation, function ($query) use ($column, $operator, $value) {
182+
if ($column instanceof Closure) {
183+
$column($query);
184+
} else {
185+
$query->where($column, $operator, $value);
186+
}
187+
});
188+
}
189+
190+
/**
191+
* Add an "or where" clause to a relationship query.
192+
*
193+
* @param string $relation
194+
* @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column
195+
* @param mixed $operator
196+
* @param mixed $value
197+
* @return \Statamic\Query\Builder|static
198+
*/
199+
public function orWhereRelation($relation, $column, $operator = null, $value = null)
200+
{
201+
return $this->orWhereHas($relation, function ($query) use ($column, $operator, $value) {
202+
if ($column instanceof Closure) {
203+
$column($query);
204+
} else {
205+
$query->where($column, $operator, $value);
206+
}
207+
});
208+
}
209+
210+
/**
211+
* Get the blueprints available to this query builder
212+
*
213+
* @return \Illuminate\Support\Collection
214+
*/
215+
protected function getBlueprintsForRelations()
216+
{
217+
return collect();
218+
}
219+
220+
/**
221+
* Get the query builder and field for the relation we are querying (if they exist)
222+
*
223+
* @param string $relation
224+
* @return \Statamic\Query\Builder
225+
*/
226+
protected function getRelationQueryBuilderAndField($relation)
227+
{
228+
$relationField = $this->getBlueprintsForRelations()
229+
->flatMap(function ($blueprint) use ($relation) {
230+
return $blueprint->fields()->all()->map(function ($field) use ($relation) {
231+
if ($field->handle() == $relation && $field->fieldtype()->isRelationship()) {
232+
return $field;
233+
}
234+
})
235+
->filter()
236+
->values();
237+
})
238+
->filter()
239+
->first();
240+
241+
if (! $relationField) {
242+
throw new InvalidArgumentException("Relation {$relation} does not exist");
243+
}
244+
245+
$queryBuilder = $relationField->fieldtype()->relationshipQueryBuilder();
246+
247+
if (! $queryBuilder) {
248+
throw new InvalidArgumentException("Relation {$relation} does not support subquerying");
249+
}
250+
251+
return [$queryBuilder, $relationField];
252+
}
253+
}

src/Stache/Query/EntryQueryBuilder.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,23 @@ protected function getWhereColumnKeyValuesByIndex($column)
154154
});
155155
}
156156

157+
protected function getBlueprintsForRelations()
158+
{
159+
$collections = empty($this->collections)
160+
? Facades\Collection::all()
161+
: $this->collections;
162+
163+
return collect($collections)->flatMap(function ($collection) {
164+
if (is_string($collection)) {
165+
$collection = Facades\Collection::find($collection);
166+
}
167+
168+
return $collection ? $collection->entryBlueprints() : false;
169+
})
170+
->filter()
171+
->unique();
172+
}
173+
157174
private function ensureCollectionsAreQueriedForStatusQuery(): void
158175
{
159176
// If the collections property isn't empty, it means the user has explicitly

src/Stache/Query/TermQueryBuilder.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,23 @@ protected function getWhereColumnKeyValuesByIndex($column)
178178
return $items;
179179
}
180180

181+
protected function getBlueprintsForRelations()
182+
{
183+
$taxonomies = empty($this->taxonomies)
184+
? Facades\Taxonomy::handles()
185+
: $this->taxonomies;
186+
187+
return collect($taxonomies)->flatMap(function ($taxonomy) {
188+
if (is_string($taxonomy)) {
189+
$taxonomy = Facades\Taxonomy::find($taxonomy);
190+
}
191+
192+
return $taxonomy ? $taxonomy->termBlueprints() : false;
193+
})
194+
->filter()
195+
->unique();
196+
}
197+
181198
public function prepareForFakeQuery(): array
182199
{
183200
$data = parent::prepareForFakeQuery();

src/Stache/Query/UserQueryBuilder.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Statamic\Stache\Query;
44

55
use Statamic\Auth\UserCollection;
6+
use Statamic\Facades\User;
67

78
class UserQueryBuilder extends Builder
89
{
@@ -116,4 +117,9 @@ protected function getOrderKeyValuesByIndex()
116117
return [$orderBy->sort => $items];
117118
});
118119
}
120+
121+
protected function getBlueprintsForRelations()
122+
{
123+
return collect([User::make()->blueprint()]);
124+
}
119125
}

0 commit comments

Comments
 (0)