diff --git a/src/Fields/Fieldtype.php b/src/Fields/Fieldtype.php index bdf18fa9ba..360a5e081d 100644 --- a/src/Fields/Fieldtype.php +++ b/src/Fields/Fieldtype.php @@ -363,6 +363,11 @@ public function isRelationship(): bool return $this->relationship; } + public function relationshipQueryBuilder() + { + return false; + } + public function toQueryableValue($value) { return $value; diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 48b2795c9d..c6f0512392 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -445,6 +445,14 @@ protected function getItemsForPreProcessIndex($values): SupportCollection return $this->queryBuilder($values)->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); diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php index 3b4770999e..64134f9ad9 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -480,6 +480,14 @@ 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)); + } + public function getItemHint($item): ?string { return collect([ diff --git a/src/Fieldtypes/Users.php b/src/Fieldtypes/Users.php index b3e9317701..5f1910aa0b 100644 --- a/src/Fieldtypes/Users.php +++ b/src/Fieldtypes/Users.php @@ -224,4 +224,9 @@ public function filter() { return new UserFilter($this); } + + public function relationshipQueryBuilder() + { + return User::query(); + } } diff --git a/src/Query/Builder.php b/src/Query/Builder.php index e9bd131737..84ca1f723d 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -19,6 +19,7 @@ abstract class Builder implements Contract { use AppliesScopes, FakesQueries; + use Traits\QueriesRelationships; protected $columns; protected $limit; diff --git a/src/Query/Traits/QueriesRelationships.php b/src/Query/Traits/QueriesRelationships.php new file mode 100644 index 0000000000..91683b8d8c --- /dev/null +++ b/src/Query/Traits/QueriesRelationships.php @@ -0,0 +1,244 @@ +=', $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]; + } +} diff --git a/src/Stache/Query/EntryQueryBuilder.php b/src/Stache/Query/EntryQueryBuilder.php index d20fe0c77b..821008c643 100644 --- a/src/Stache/Query/EntryQueryBuilder.php +++ b/src/Stache/Query/EntryQueryBuilder.php @@ -143,6 +143,23 @@ protected function getWhereColumnKeyValuesByIndex($column) }); } + protected function getBlueprintsForRelations() + { + $collections = empty($this->collections) + ? Facades\Collection::all() + : $this->collections; + + return collect($collections)->flatMap(function ($collection) { + if (is_string($collection)) { + $collection = Facades\Collection::find($collection); + } + + return $collection ? $collection->entryBlueprints() : false; + }) + ->filter() + ->unique(); + } + private function ensureCollectionsAreQueriedForStatusQuery(): void { // If the collections property isn't empty, it means the user has explicitly diff --git a/src/Stache/Query/TermQueryBuilder.php b/src/Stache/Query/TermQueryBuilder.php index deda55b377..e02bdf86d2 100644 --- a/src/Stache/Query/TermQueryBuilder.php +++ b/src/Stache/Query/TermQueryBuilder.php @@ -178,6 +178,23 @@ protected function getWhereColumnKeyValuesByIndex($column) return $items; } + protected function getBlueprintsForRelations() + { + $taxonomies = empty($this->taxonomies) + ? Facades\Taxonomy::handles() + : $this->taxonomies; + + return collect($taxonomies)->flatMap(function ($taxonomy) { + if (is_string($taxonomy)) { + $taxonomy = Facades\Taxonomy::find($taxonomy); + } + + return $taxonomy ? $taxonomy->termBlueprints() : false; + }) + ->filter() + ->unique(); + } + public function prepareForFakeQuery(): array { $data = parent::prepareForFakeQuery(); diff --git a/src/Stache/Query/UserQueryBuilder.php b/src/Stache/Query/UserQueryBuilder.php index 49be86af18..da3e82fdfb 100644 --- a/src/Stache/Query/UserQueryBuilder.php +++ b/src/Stache/Query/UserQueryBuilder.php @@ -3,6 +3,7 @@ namespace Statamic\Stache\Query; use Statamic\Auth\UserCollection; +use Statamic\Facades\User; class UserQueryBuilder extends Builder { @@ -116,4 +117,9 @@ protected function getOrderKeyValuesByIndex() return [$orderBy->sort => $items]; }); } + + protected function getBlueprintsForRelations() + { + return collect([User::make()->blueprint()]); + } } diff --git a/tests/Data/Entries/EntryQueryBuilderTest.php b/tests/Data/Entries/EntryQueryBuilderTest.php index 75c6706dbe..8752a00bbf 100644 --- a/tests/Data/Entries/EntryQueryBuilderTest.php +++ b/tests/Data/Entries/EntryQueryBuilderTest.php @@ -696,6 +696,116 @@ public function entries_are_found_using_offset() $this->assertEquals(['Post 2', 'Post 3'], $entries->map->title->all()); } + #[Test] + public function entries_are_found_using_where_has_when_max_items_1() + { + $blueprint = Blueprint::makeFromFields(['entries_field' => ['type' => 'entries', 'max_items' => 1]]); + Blueprint::shouldReceive('in')->with('collections/posts')->andReturn(collect(['posts' => $blueprint])); + + $this->createDummyCollectionAndEntries(); + + Entry::find('id-1') + ->merge([ + 'entries_field' => 'id-2', + ]) + ->save(); + + Entry::find('id-3') + ->merge([ + 'entries_field' => 'id-1', + ]) + ->save(); + + $entries = Entry::query()->whereHas('entries_field')->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Post 1', 'Post 3'], $entries->map->title->all()); + + $entries = Entry::query()->whereHas('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(1, $entries); + $this->assertEquals(['Post 1'], $entries->map->title->all()); + + $entries = Entry::query()->whereDoesntHave('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Post 2', 'Post 3'], $entries->map->title->all()); + } + + #[Test] + public function entries_are_found_using_where_has_when_max_items_not_1() + { + $blueprint = Blueprint::makeFromFields(['entries_field' => ['type' => 'entries']]); + Blueprint::shouldReceive('in')->with('collections/posts')->andReturn(collect(['posts' => $blueprint])); + + $this->createDummyCollectionAndEntries(); + + Entry::find('id-1') + ->merge([ + 'entries_field' => ['id-2', 'id-1'], + ]) + ->save(); + + Entry::find('id-3') + ->merge([ + 'entries_field' => ['id-1', 'id-2'], + ]) + ->save(); + + $entries = Entry::query()->whereHas('entries_field')->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Post 1', 'Post 3'], $entries->map->title->all()); + + $entries = Entry::query()->whereHas('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Post 1', 'Post 3'], $entries->map->title->all()); + + $entries = Entry::query()->whereDoesntHave('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(1, $entries); + $this->assertEquals(['Post 2'], $entries->map->title->all()); + } + + #[Test] + public function entries_are_found_using_where_relation() + { + $blueprint = Blueprint::makeFromFields(['entries_field' => ['type' => 'entries']]); + Blueprint::shouldReceive('in')->with('collections/posts')->andReturn(collect(['posts' => $blueprint])); + + $this->createDummyCollectionAndEntries(); + + Entry::find('id-1') + ->merge([ + 'entries_field' => ['id-2', 'id-1'], + ]) + ->save(); + + Entry::find('id-3') + ->merge([ + 'entries_field' => ['id-1', 'id-2'], + ]) + ->save(); + + $entries = Entry::query()->whereRelation('entries_field', 'title', 'Post 2')->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Post 1', 'Post 3'], $entries->map->title->all()); + } + #[Test] #[DataProvider('likeProvider')] public function entries_are_found_using_like($like, $expected) diff --git a/tests/Data/Taxonomies/TermQueryBuilderTest.php b/tests/Data/Taxonomies/TermQueryBuilderTest.php index b7136ede31..9e73550650 100644 --- a/tests/Data/Taxonomies/TermQueryBuilderTest.php +++ b/tests/Data/Taxonomies/TermQueryBuilderTest.php @@ -618,6 +618,89 @@ public function terms_are_found_using_offset() $terms = Term::query()->offset(1)->get(); $this->assertEquals(['b', 'c'], $terms->map->slug()->all()); } + + /** @test **/ + public function terms_are_found_using_where_has_when_max_items_1() + { + $blueprint = Blueprint::makeFromFields(['terms_field' => ['type' => 'terms', 'max_items' => 1]]); + Blueprint::shouldReceive('in')->with('taxonomies/tags')->andReturn(collect(['tags' => $blueprint])); + + Taxonomy::make('tags')->save(); + Term::make('a')->taxonomy('tags')->data([])->save(); + Term::make('b')->taxonomy('tags')->data(['terms_field' => 'a'])->save(); + Term::make('c')->taxonomy('tags')->data(['terms_field' => 'b'])->save(); + + $terms = Term::query()->whereHas('terms_field')->get(); + + $this->assertCount(2, $terms); + $this->assertEquals(['b', 'c'], $terms->map->slug->all()); + + $terms = Term::query()->whereHas('terms_field', function ($subquery) { + $subquery->where('title', 'a'); + }) + ->get(); + + $this->assertCount(1, $terms); + $this->assertEquals(['b'], $terms->map->slug->all()); + + $terms = Term::query()->whereDoesntHave('terms_field', function ($subquery) { + $subquery->where('title', 'a'); + }) + ->get(); + + $this->assertCount(2, $terms); + $this->assertEquals(['a', 'c'], $terms->map->slug->all()); + } + + /** @test **/ + public function terms_are_found_using_where_has_when_max_items_not_1() + { + $blueprint = Blueprint::makeFromFields(['terms_field' => ['type' => 'terms', 'max_items' => 1]]); + Blueprint::shouldReceive('in')->with('taxonomies/tags')->andReturn(collect(['tags' => $blueprint])); + + Taxonomy::make('tags')->save(); + Term::make('a')->taxonomy('tags')->data([])->save(); + Term::make('b')->taxonomy('tags')->data(['terms_field' => ['a', 'c']])->save(); + Term::make('c')->taxonomy('tags')->data(['terms_field' => ['b', 'a']])->save(); + + $terms = Term::query()->whereHas('terms_field')->get(); + + $this->assertCount(2, $terms); + $this->assertEquals(['b', 'c'], $terms->map->slug->all()); + + $terms = Term::query()->whereHas('terms_field', function ($subquery) { + $subquery->where('slug', 'b'); + }) + ->get(); + + $this->assertCount(1, $terms); + $this->assertEquals(['c'], $terms->map->slug->all()); + + $terms = Term::query()->whereDoesntHave('terms_field', function ($subquery) { + $subquery->where('title', 'b'); + }) + ->get(); + + $this->assertCount(2, $terms); + $this->assertEquals(['a', 'b'], $terms->map->slug->all()); + } + + /** @test **/ + public function terms_are_found_using_where_relation() + { + $blueprint = Blueprint::makeFromFields(['terms_field' => ['type' => 'terms', 'max_items' => 1]]); + Blueprint::shouldReceive('in')->with('taxonomies/tags')->andReturn(collect(['tags' => $blueprint])); + + Taxonomy::make('tags')->save(); + Term::make('a')->taxonomy('tags')->data([])->save(); + Term::make('b')->taxonomy('tags')->data(['terms_field' => ['a', 'c']])->save(); + Term::make('c')->taxonomy('tags')->data(['terms_field' => ['b', 'a']])->save(); + + $terms = Term::query()->whereRelation('terms_field', 'slug', 'b')->get(); + + $this->assertCount(1, $terms); + $this->assertEquals(['c'], $terms->map->slug->all()); + } } class CustomScope extends Scope diff --git a/tests/Data/Users/UserQueryBuilderTest.php b/tests/Data/Users/UserQueryBuilderTest.php index 1787094179..2ce099cb07 100644 --- a/tests/Data/Users/UserQueryBuilderTest.php +++ b/tests/Data/Users/UserQueryBuilderTest.php @@ -2,7 +2,10 @@ namespace Tests\Data\Users; +use Facades\Tests\Factories\EntryFactory; use PHPUnit\Framework\Attributes\Test; +use Statamic\Facades\Blueprint; +use Statamic\Facades\Collection; use Statamic\Facades\Role; use Statamic\Facades\User; use Statamic\Facades\UserGroup; @@ -213,6 +216,93 @@ public function users_are_found_using_tap() $this->assertEquals(['Gandalf'], $users->map->name->all()); } + #[Test] + public function users_are_found_using_where_has_when_max_items_1() + { + $this->createDummyCollectionAndEntries(); + + $blueprint = Blueprint::makeFromFields(['entries_field' => ['type' => 'entries', 'max_items' => 1]]); + Blueprint::shouldReceive('find')->with('user')->andReturn($blueprint); + + User::make()->email('gandalf@precious.com')->data(['name' => 'Gandalf', 'entries_field' => 2])->save(); + User::make()->email('smeagol@precious.com')->data(['name' => 'Smeagol'])->save(); + User::make()->email('frodo@precious.com')->data(['name' => 'Frodo', 'entries_field' => 1])->save(); + + $entries = User::query()->whereHas('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(1, $entries); + $this->assertEquals(['Gandalf'], $entries->map->name->all()); + + $entries = User::query()->whereDoesntHave('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(2, $entries); + $this->assertEquals(['Smeagol', 'Frodo'], $entries->map->name->all()); + } + + #[Test] + public function users_are_found_using_where_has_when_max_items_not_1() + { + $this->createDummyCollectionAndEntries(); + + $blueprint = Blueprint::makeFromFields(['entries_field' => ['type' => 'entries']]); + Blueprint::shouldReceive('find')->with('user')->andReturn($blueprint); + + User::make()->email('gandalf@precious.com')->data(['name' => 'Gandalf', 'entries_field' => [2, 1]])->save(); + User::make()->email('smeagol@precious.com')->data(['name' => 'Smeagol'])->save(); + User::make()->email('frodo@precious.com')->data(['name' => 'Frodo', 'entries_field' => [1, 2]])->save(); + + $users = User::query()->whereHas('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(2, $users); + $this->assertEquals(['Gandalf', 'Frodo'], $users->map->name->all()); + + $users = User::query()->whereDoesntHave('entries_field', function ($subquery) { + $subquery->where('title', 'Post 2'); + }) + ->get(); + + $this->assertCount(1, $users); + $this->assertEquals(['Smeagol'], $users->map->name->all()); + } + + #[Test] + public function users_are_found_using_where_relation() + { + $this->createDummyCollectionAndEntries(); + + $blueprint = Blueprint::makeFromFields(['entries_field' => ['type' => 'entries']]); + Blueprint::shouldReceive('find')->with('user')->andReturn($blueprint); + + User::make()->email('gandalf@precious.com')->data(['name' => 'Gandalf', 'entries_field' => [2, 1]])->save(); + User::make()->email('smeagol@precious.com')->data(['name' => 'Smeagol'])->save(); + User::make()->email('frodo@precious.com')->data(['name' => 'Frodo', 'entries_field' => [1, 2]])->save(); + + $users = User::query()->whereRelation('entries_field', 'title', 'Post 2')->get(); + + $this->assertCount(2, $users); + $this->assertEquals(['Gandalf', 'Frodo'], $users->map->name->all()); + } + + private function createDummyCollectionAndEntries() + { + Collection::make('posts')->save(); + + EntryFactory::id('1')->slug('post-1')->collection('posts')->data(['title' => 'Post 1', 'author' => 'John Doe'])->create(); + $entry = EntryFactory::id('2')->slug('post-2')->collection('posts')->data(['title' => 'Post 2', 'author' => 'John Doe'])->create(); + EntryFactory::id('3')->slug('post-3')->collection('posts')->data(['title' => 'Post 3', 'author' => 'John Doe'])->create(); + + return $entry; + } + #[Test] public function users_are_found_using_where_group() {