From 7a09d9e2f3b937345efdf0b7fc42fe474f5e7274 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Thu, 12 Nov 2026 21:21:42 +0000 Subject: [PATCH 1/5] Don't query for the entries of an empty relationship A listing column for an entries field with nothing selected ran two queries across every collection for whereIn('id', []), which can't match. Nothing is looked for when nothing is selected, and a where in an empty list no longer tests every value in the index. --- src/Fieldtypes/Entries.php | 5 +++++ src/Stache/Query/Builder.php | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 8b070789cac..b66ee8b8747 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -516,6 +516,11 @@ public function getColumns() protected function getItemsForPreProcessIndex($values): SupportCollection { + // Nothing selected, so there's nothing to go looking for. + if (empty(Arr::wrap($values))) { + return $this->collect([]); + } + return $this->queryBuilder($values)->whereAnyStatus()->get(); } diff --git a/src/Stache/Query/Builder.php b/src/Stache/Query/Builder.php index 9ce2f15ffc3..6f409494dfb 100644 --- a/src/Stache/Query/Builder.php +++ b/src/Stache/Query/Builder.php @@ -157,6 +157,11 @@ protected function filterWhereBasic($values, $where) protected function filterWhereIn($values, $where) { + // Nothing can be in an empty list. + if (empty($where['values'])) { + return $values->take(0); + } + $lookup = array_flip(array_map($this->normalizeLookupValue(...), $where['values'])); return $values->filter( From 2cb6038549f127e3c2a210c53e91cae2aec15ec4 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Thu, 12 Nov 2026 22:24:53 +0000 Subject: [PATCH 2/5] Pick entries out of the id index when a query asks for them by id whereIn('id', [...]) tested every id in every collection to find one or two. An entry's key in its collection's id index is its id, so the ones asked for are picked out directly, in the order the index holds them. If any aren't found that way the query filters as before, since ids are compared without regard to case. --- src/Stache/Query/EntryQueryBuilder.php | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/Stache/Query/EntryQueryBuilder.php b/src/Stache/Query/EntryQueryBuilder.php index cddccfd347a..934eae044d9 100644 --- a/src/Stache/Query/EntryQueryBuilder.php +++ b/src/Stache/Query/EntryQueryBuilder.php @@ -149,6 +149,10 @@ protected function getKeysFromCollectionsWithWhere($collections, $where) protected function getKeysFromCollectionsById($collections, $where) { + if ($where['type'] === 'In' && $where['column'] === 'id') { + return $this->getKeysFromCollectionsByIds($collections, $where['values']); + } + if ($where['type'] !== 'Basic' || $where['column'] !== 'id' || $where['operator'] !== '=' || ! is_string($where['value'])) { return null; } @@ -162,6 +166,29 @@ protected function getKeysFromCollectionsById($collections, $where) return $keys->isEmpty() ? null : $keys; } + private function getKeysFromCollectionsByIds($collections, $ids) + { + if (empty($ids) || count(array_filter($ids, 'is_string')) !== count($ids)) { + return null; + } + + $wanted = array_flip($ids); + $found = []; + + // An entry's key in its collection's id index is its id, so the ones asked for + // can be picked out, in the order the index holds them, without testing the rest. + $keys = $collections->flatMap(function ($collection) use ($wanted, &$found) { + $ids = array_keys(array_intersect_key($this->store->store($collection)->index('id')->items()->all(), $wanted)); + + $found += array_flip($ids); + + return array_map(fn ($id) => "{$collection}::{$id}", $ids); + })->values(); + + // Ids are compared case-insensitively, so if any weren't found this way, fall back to filtering. + return count($found) === count($wanted) ? $keys : null; + } + protected function getOrderKeyValuesByIndex() { $collections = empty($this->collections) From 7512d3a99b13548a202515fda97a39ed0fcd4f04 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Thu, 12 Nov 2026 23:39:37 +0000 Subject: [PATCH 3/5] Test empty lists and lists of ids --- tests/Data/Entries/EntryQueryBuilderTest.php | 40 ++++++++++++++++++++ tests/Fieldtypes/EntriesTest.php | 11 ++++++ 2 files changed, 51 insertions(+) diff --git a/tests/Data/Entries/EntryQueryBuilderTest.php b/tests/Data/Entries/EntryQueryBuilderTest.php index a31ec9b505d..bc2da5193ae 100644 --- a/tests/Data/Entries/EntryQueryBuilderTest.php +++ b/tests/Data/Entries/EntryQueryBuilderTest.php @@ -77,6 +77,46 @@ public function entries_are_found_using_or_where_in() $this->assertEquals(['Post 1', 'Post 2', 'Post 4', 'Post 5'], $entries->map->title->all()); } + #[Test] + public function nothing_is_found_in_an_empty_list() + { + $this->createDummyCollectionAndEntries(); + + $this->assertSame([], Entry::query()->whereIn('id', [])->get()->map->id()->all()); + $this->assertSame([], Entry::query()->whereIn('title', [])->get()->map->id()->all()); + $this->assertSame(['id-1'], Entry::query()->where('title', 'Post 1')->orWhereIn('id', [])->get()->map->id()->all()); + $this->assertSame(['id-1', 'id-2', 'id-3'], Entry::query()->whereNotIn('id', [])->get()->map->id()->all()); + } + + #[Test] + public function entries_are_found_by_a_list_of_ids() + { + $this->createDummyCollectionAndEntries(); + Collection::make('pages')->save(); + EntryFactory::id('page-1')->slug('page-1')->collection('pages')->data(['title' => 'Page 1'])->create(); + EntryFactory::id('4242')->slug('numbered')->collection('pages')->data(['title' => 'Numbered'])->create(); + + $ids = fn ($query) => $query->get()->map->id()->all(); + + // In the order the collections and their entries are stored, not the order asked for. + $this->assertSame(['id-1', 'id-3', 'page-1'], $ids(Entry::query()->whereIn('id', ['page-1', 'id-3', 'id-1']))); + $this->assertSame(['id-3'], $ids(Entry::query()->where('collection', 'posts')->whereIn('id', ['page-1', 'id-3']))); + $this->assertSame(['4242'], $ids(Entry::query()->whereIn('id', ['4242']))); + $this->assertSame(['id-2'], $ids(Entry::query()->whereIn('id', ['id-2', 'id-2']))); + + // One that isn't there doesn't stop the others being found. + $this->assertSame(['id-2'], $ids(Entry::query()->whereIn('id', ['id-2', 'missing']))); + $this->assertSame([], $ids(Entry::query()->whereIn('id', ['missing']))); + + // Ids are compared without regard to case. + $this->assertSame(['id-1', 'id-2'], $ids(Entry::query()->whereIn('id', ['ID-2', 'id-1']))); + + // A list that isn't all strings. + $this->assertSame(['id-1', '4242'], $ids(Entry::query()->whereIn('id', ['id-1', 4242]))); + + $this->assertSame(['id-1', 'page-1'], $ids(Entry::query()->whereIn('id', ['id-1'])->orWhereIn('id', ['page-1']))); + } + #[Test] public function where_in_and_where_not_in_are_case_insensitive() { diff --git a/tests/Fieldtypes/EntriesTest.php b/tests/Fieldtypes/EntriesTest.php index a13bcc6a0ad..84c91673cd1 100644 --- a/tests/Fieldtypes/EntriesTest.php +++ b/tests/Fieldtypes/EntriesTest.php @@ -134,6 +134,17 @@ public function it_returns_empty_collection_when_preprocessing_index_and_max_ite $this->assertEquals([], $preProcessed->all()); } + #[Test] + public function it_returns_an_empty_collection_when_preprocessing_index_and_nothing_is_selected() + { + foreach ([null, []] as $value) { + $preProcessed = $this->fieldtype()->preProcessIndex($value); + + $this->assertInstanceOf(Collection::class, $preProcessed); + $this->assertCount(0, $preProcessed); + } + } + #[Test] public function it_includes_drafts_when_pre_processing_for_index() { From c698af4b0c3b29710ba7aae77b55440fb65d1698 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Fri, 13 Nov 2026 00:07:00 +0000 Subject: [PATCH 4/5] Revert "Pick entries out of the id index when a query asks for them by id" This reverts commit 2cb6038549f127e3c2a210c53e91cae2aec15ec4. --- src/Stache/Query/EntryQueryBuilder.php | 27 -------------------------- 1 file changed, 27 deletions(-) diff --git a/src/Stache/Query/EntryQueryBuilder.php b/src/Stache/Query/EntryQueryBuilder.php index 934eae044d9..cddccfd347a 100644 --- a/src/Stache/Query/EntryQueryBuilder.php +++ b/src/Stache/Query/EntryQueryBuilder.php @@ -149,10 +149,6 @@ protected function getKeysFromCollectionsWithWhere($collections, $where) protected function getKeysFromCollectionsById($collections, $where) { - if ($where['type'] === 'In' && $where['column'] === 'id') { - return $this->getKeysFromCollectionsByIds($collections, $where['values']); - } - if ($where['type'] !== 'Basic' || $where['column'] !== 'id' || $where['operator'] !== '=' || ! is_string($where['value'])) { return null; } @@ -166,29 +162,6 @@ protected function getKeysFromCollectionsById($collections, $where) return $keys->isEmpty() ? null : $keys; } - private function getKeysFromCollectionsByIds($collections, $ids) - { - if (empty($ids) || count(array_filter($ids, 'is_string')) !== count($ids)) { - return null; - } - - $wanted = array_flip($ids); - $found = []; - - // An entry's key in its collection's id index is its id, so the ones asked for - // can be picked out, in the order the index holds them, without testing the rest. - $keys = $collections->flatMap(function ($collection) use ($wanted, &$found) { - $ids = array_keys(array_intersect_key($this->store->store($collection)->index('id')->items()->all(), $wanted)); - - $found += array_flip($ids); - - return array_map(fn ($id) => "{$collection}::{$id}", $ids); - })->values(); - - // Ids are compared case-insensitively, so if any weren't found this way, fall back to filtering. - return count($found) === count($wanted) ? $keys : null; - } - protected function getOrderKeyValuesByIndex() { $collections = empty($this->collections) From b1cc1c218510b320f203611dc58e638d81d57b0d Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Fri, 13 Nov 2026 00:07:07 +0000 Subject: [PATCH 5/5] Leave looking entries up by a list of ids for another change --- tests/Data/Entries/EntryQueryBuilderTest.php | 29 -------------------- 1 file changed, 29 deletions(-) diff --git a/tests/Data/Entries/EntryQueryBuilderTest.php b/tests/Data/Entries/EntryQueryBuilderTest.php index bc2da5193ae..9078a777ad3 100644 --- a/tests/Data/Entries/EntryQueryBuilderTest.php +++ b/tests/Data/Entries/EntryQueryBuilderTest.php @@ -88,35 +88,6 @@ public function nothing_is_found_in_an_empty_list() $this->assertSame(['id-1', 'id-2', 'id-3'], Entry::query()->whereNotIn('id', [])->get()->map->id()->all()); } - #[Test] - public function entries_are_found_by_a_list_of_ids() - { - $this->createDummyCollectionAndEntries(); - Collection::make('pages')->save(); - EntryFactory::id('page-1')->slug('page-1')->collection('pages')->data(['title' => 'Page 1'])->create(); - EntryFactory::id('4242')->slug('numbered')->collection('pages')->data(['title' => 'Numbered'])->create(); - - $ids = fn ($query) => $query->get()->map->id()->all(); - - // In the order the collections and their entries are stored, not the order asked for. - $this->assertSame(['id-1', 'id-3', 'page-1'], $ids(Entry::query()->whereIn('id', ['page-1', 'id-3', 'id-1']))); - $this->assertSame(['id-3'], $ids(Entry::query()->where('collection', 'posts')->whereIn('id', ['page-1', 'id-3']))); - $this->assertSame(['4242'], $ids(Entry::query()->whereIn('id', ['4242']))); - $this->assertSame(['id-2'], $ids(Entry::query()->whereIn('id', ['id-2', 'id-2']))); - - // One that isn't there doesn't stop the others being found. - $this->assertSame(['id-2'], $ids(Entry::query()->whereIn('id', ['id-2', 'missing']))); - $this->assertSame([], $ids(Entry::query()->whereIn('id', ['missing']))); - - // Ids are compared without regard to case. - $this->assertSame(['id-1', 'id-2'], $ids(Entry::query()->whereIn('id', ['ID-2', 'id-1']))); - - // A list that isn't all strings. - $this->assertSame(['id-1', '4242'], $ids(Entry::query()->whereIn('id', ['id-1', 4242]))); - - $this->assertSame(['id-1', 'page-1'], $ids(Entry::query()->whereIn('id', ['id-1'])->orWhereIn('id', ['page-1']))); - } - #[Test] public function where_in_and_where_not_in_are_case_insensitive() {