From 7ef99a065bdeffc73b98e8155fe0d1df47bc8ca4 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Fri, 13 Nov 2026 13:46:21 +0000 Subject: [PATCH 1/2] Only look among the entries found so far when a query narrows them further --- src/Stache/Query/EntryQueryBuilder.php | 43 ++++++++++++-- tests/Data/Entries/EntryQueryBuilderTest.php | 59 ++++++++++++++++++++ 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/Stache/Query/EntryQueryBuilder.php b/src/Stache/Query/EntryQueryBuilder.php index cddccfd347a..b4c5e635677 100644 --- a/src/Stache/Query/EntryQueryBuilder.php +++ b/src/Stache/Query/EntryQueryBuilder.php @@ -108,18 +108,41 @@ protected function getKeysFromCollections($collections) }); } - protected function getKeysFromCollectionsWithWheres($collections, $wheres) + protected function getKeysFromCollectionsWithWheres($collections, $wheres, $within = null) { - return collect($wheres)->reduce(function ($ids, $where) use ($collections) { + return collect($wheres)->reduce(function ($ids, $where) use ($collections, $within) { + // What an "and" finds is only kept where it's among the keys there are so far, + // so once those are few, they're all that needs looking through. + $among = $this->keysToLookAmong($ids, $where, $within); + $keys = $where['type'] == 'Nested' - ? $this->getKeysFromCollectionsWithWheres($collections, $where['query']->wheres) - : $this->getKeysFromCollectionsWithWhere($collections, $where); + ? $this->getKeysFromCollectionsWithWheres($collections, $where['query']->wheres, $among) + : $this->getKeysFromCollectionsWithWhere($collections, $where, $among); return $this->intersectKeysFromWhereClause($ids, $keys, $where); }); } - protected function getKeysFromCollectionsWithWhere($collections, $where) + private function keysToLookAmong($ids, $where, $within) + { + if (! $ids || ($where['boolean'] === 'or' && $where['type'] !== 'NotIn')) { + return $within; + } + + if ($ids->count() > 250) { + return $within; + } + + return $ids + ->mapToGroups(function ($id) { + [$collection, $key] = explode('::', $id, 2); + + return [$collection => $key]; + }) + ->map(fn ($keys) => $keys->flip()); + } + + protected function getKeysFromCollectionsWithWhere($collections, $where, $within = null) { // A collection can be queried twice (e.g. by a status query), but its keys must only appear once. $collections = collect($collections)->unique()->values(); @@ -133,9 +156,17 @@ protected function getKeysFromCollectionsWithWhere($collections, $where) // These filters only look at values, so each store's index can be filtered // before its keys are prefixed, instead of prefixing every key first. if (in_array($where['type'], ['Basic', 'In'])) { - return $collections->flatMap(function ($collection) use ($where, $method) { + return $collections->flatMap(function ($collection) use ($where, $method, $within) { + if ($within && ! $within->has($collection)) { + return []; + } + $items = $this->store->store($collection)->index($where['column'])->items(); + if ($within) { + $items = $items->intersectByKeys($within[$collection]); + } + return $this->{$method}($items, $where)->keys()->map(fn ($key) => "{$collection}::{$key}"); })->values(); } diff --git a/tests/Data/Entries/EntryQueryBuilderTest.php b/tests/Data/Entries/EntryQueryBuilderTest.php index a31ec9b505d..54b35a028cf 100644 --- a/tests/Data/Entries/EntryQueryBuilderTest.php +++ b/tests/Data/Entries/EntryQueryBuilderTest.php @@ -1700,6 +1700,65 @@ public function sorting_by_unsafe_method_does_not_invoke_it() $this->assertCount($count, Entry::all()); } + + #[Test] + public function a_where_after_a_few_entries_have_been_found_only_looks_at_those() + { + Collection::make('posts')->save(); + collect(range(1, 20))->each(fn ($i) => EntryFactory::id('id-'.$i)->slug('post-'.$i)->collection('posts')->data(['title' => 'Post '.$i, 'colour' => $i % 2 ? 'red' : 'blue'])->create()); + + $query = new CountingEntryQueryBuilder(app(\Statamic\Stache\Stache::class)->store('entries')); + + $entries = $query->where('collection', 'posts')->whereIn('id', ['id-3', 'id-4', 'id-5'])->where('colour', 'red')->get(); + + $this->assertEquals(['id-3', 'id-5'], $entries->map->id()->all()); + $this->assertSame(3, $query->tested); + } + + #[Test] + #[DataProvider('narrowedQueryProvider')] + public function wheres_after_a_few_entries_have_been_found_find_what_they_would_among_all_of_them($expected, $callback) + { + Collection::make('posts')->save(); + Collection::make('things')->save(); + collect(range(1, 12))->each(fn ($i) => EntryFactory::id('id-'.$i)->slug('post-'.$i)->collection($i > 9 ? 'things' : 'posts')->data(['title' => 'Post '.$i, 'colour' => $i % 2 ? 'red' : 'blue', 'size' => $i % 3 ? 'big' : 'small'])->create()); + + $query = Entry::query()->whereIn('id', ['id-6', 'id-3', 'id-4', 'id-5', 'id-11', 'id-12']); + + $this->assertEquals($expected, $callback($query)->get()->map->id()->all()); + } + + public static function narrowedQueryProvider() + { + return [ + 'and' => [['id-3', 'id-5', 'id-11'], fn ($q) => $q->where('colour', 'red')], + 'and, and' => [['id-5', 'id-11'], fn ($q) => $q->where('colour', 'red')->where('size', 'big')], + 'and in' => [['id-3', 'id-6', 'id-12'], fn ($q) => $q->whereIn('size', ['small'])], + 'and not in' => [['id-4', 'id-5', 'id-11'], fn ($q) => $q->whereNotIn('size', ['small'])], + 'and, or' => [['id-3', 'id-5', 'id-11', 'id-1'], fn ($q) => $q->where('colour', 'red')->orWhere('title', 'Post 1')], + 'and, or, and' => [['id-3', 'id-5', 'id-11', 'id-2'], fn ($q) => $q->where('colour', 'red')->orWhere('colour', 'blue')->whereIn('title', ['Post 3', 'Post 5', 'Post 11', 'Post 2'])], + 'and, or not in' => [['id-5', 'id-11'], fn ($q) => $q->where('colour', 'red')->orWhereNotIn('size', ['small'])], + 'and nested' => [['id-3', 'id-4', 'id-5', 'id-11'], fn ($q) => $q->where(fn ($q) => $q->where('colour', 'red')->orWhere('title', 'Post 4')->orWhere('title', 'Post 1'))], + 'and nested with an and inside' => [['id-5', 'id-11'], fn ($q) => $q->where(fn ($q) => $q->where('colour', 'red')->where('size', 'big'))], + 'or nested' => [['id-3', 'id-4', 'id-5', 'id-6', 'id-11', 'id-12', 'id-1', 'id-7'], fn ($q) => $q->orWhere(fn ($q) => $q->where('colour', 'red')->where('size', 'big'))], + 'and collection' => [['id-11', 'id-12'], fn ($q) => $q->where('collection', 'things')], + 'and status' => [['id-3', 'id-4', 'id-5', 'id-6', 'id-11', 'id-12'], fn ($q) => $q->whereStatus('published')], + 'and like' => [['id-11', 'id-12'], fn ($q) => $q->where('title', 'like', 'Post 1%')], + 'ordered' => [['id-3', 'id-5', 'id-11'], fn ($q) => $q->where('colour', 'red')->orderBy('title', 'asc')], + ]; + } +} + +class CountingEntryQueryBuilder extends \Statamic\Stache\Query\EntryQueryBuilder +{ + public $tested = 0; + + protected function filterTestEquals($item, $value) + { + $this->tested++; + + return parent::filterTestEquals($item, $value); + } } class CustomScope extends Scope From c4c619552d550fb56791a0175b31b494c2fb99b2 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Fri, 13 Nov 2026 13:50:34 +0000 Subject: [PATCH 2/2] Keep the signatures of the methods a query builder can override --- src/Stache/Query/EntryQueryBuilder.php | 31 +++++++++++++++++--------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/Stache/Query/EntryQueryBuilder.php b/src/Stache/Query/EntryQueryBuilder.php index b4c5e635677..6a78ee57339 100644 --- a/src/Stache/Query/EntryQueryBuilder.php +++ b/src/Stache/Query/EntryQueryBuilder.php @@ -16,6 +16,7 @@ class EntryQueryBuilder extends Builder implements QueryBuilder protected $collections = []; protected $nested = false; + private $lookingAmong; public function where($column, $operator = null, $value = null, $boolean = 'and') { @@ -108,19 +109,25 @@ protected function getKeysFromCollections($collections) }); } - protected function getKeysFromCollectionsWithWheres($collections, $wheres, $within = null) + protected function getKeysFromCollectionsWithWheres($collections, $wheres) { - return collect($wheres)->reduce(function ($ids, $where) use ($collections, $within) { - // What an "and" finds is only kept where it's among the keys there are so far, - // so once those are few, they're all that needs looking through. - $among = $this->keysToLookAmong($ids, $where, $within); + $within = $this->lookingAmong; - $keys = $where['type'] == 'Nested' - ? $this->getKeysFromCollectionsWithWheres($collections, $where['query']->wheres, $among) - : $this->getKeysFromCollectionsWithWhere($collections, $where, $among); + try { + return collect($wheres)->reduce(function ($ids, $where) use ($collections, $within) { + // What an "and" finds is only kept where it's among the keys there are so far, + // so once those are few, they're all that needs looking through. + $this->lookingAmong = $this->keysToLookAmong($ids, $where, $within); - return $this->intersectKeysFromWhereClause($ids, $keys, $where); - }); + $keys = $where['type'] == 'Nested' + ? $this->getKeysFromCollectionsWithWheres($collections, $where['query']->wheres) + : $this->getKeysFromCollectionsWithWhere($collections, $where); + + return $this->intersectKeysFromWhereClause($ids, $keys, $where); + }); + } finally { + $this->lookingAmong = $within; + } } private function keysToLookAmong($ids, $where, $within) @@ -142,8 +149,10 @@ private function keysToLookAmong($ids, $where, $within) ->map(fn ($keys) => $keys->flip()); } - protected function getKeysFromCollectionsWithWhere($collections, $where, $within = null) + protected function getKeysFromCollectionsWithWhere($collections, $where) { + $within = $this->lookingAmong; + // A collection can be queried twice (e.g. by a status query), but its keys must only appear once. $collections = collect($collections)->unique()->values();