diff --git a/src/Stache/Indexes/Terms/Value.php b/src/Stache/Indexes/Terms/Value.php index 53ef46b4645..630d3a653c6 100644 --- a/src/Stache/Indexes/Terms/Value.php +++ b/src/Stache/Indexes/Terms/Value.php @@ -2,24 +2,48 @@ namespace Statamic\Stache\Indexes\Terms; +use ReflectionMethod; +use Statamic\Facades\Entry; use Statamic\Facades\Term; use Statamic\Stache\Indexes\Value as Index; +use Statamic\Stache\Repositories\TermRepository; +use Statamic\Taxonomies\LocalizedTerm; class Value extends Index { + private ?array $publishedEntries = null; + public function getItemValue($item) { // The augmented `entries_count` value only considers published entries, so // when filtering (e.g. via the `min_count` param on the taxonomy tag) we // need to resolve the same published-only count for consistency. if ($this->name === 'entries_count') { - return Term::entriesCount($item, 'published'); + return $this->publishedEntries === null + ? Term::entriesCount($item, 'published') + : $this->countPublishedEntries($item); } return parent::getItemValue($item); } public function getItems() + { + // Counting a term's published entries is a query over every entry. Asked for one term + // at a time that's a query per term, so which entries are published is found once + // for the whole index and each term is counted from that. + if ($this->name === 'entries_count' && $this->countsEntriesTheUsualWay()) { + $this->publishedEntries = $this->publishedEntries(); + } + + try { + return $this->getTermItems(); + } finally { + $this->publishedEntries = null; + } + } + + private function getTermItems() { // There's an association for every entry that uses a term, so most terms have // many. They all make the same term, so it only needs making once per site. @@ -46,4 +70,54 @@ public function getItems() ->merge(parent::getItems()) ->all(); } + + private function publishedEntries(): array + { + $ids = []; + + foreach ($this->store->index('associations')->items() as $association) { + if ($association) { + $ids[$association['entry']] = true; + } + } + + return Entry::query() + ->whereIn('id', array_keys($ids)) + ->whereStatus('published') + ->pluck('id') + ->flip() + ->all(); + } + + /** + * The same count as TermRepository::entriesCount($term, 'published'). + */ + private function countPublishedEntries($term): int + { + if ($term->collection()) { + return Term::entriesCount($term, 'published'); + } + + $entries = []; + + foreach ($this->store->index('associations')->forValue($term->inDefaultLocale()->slug()) as $association) { + if ($term instanceof LocalizedTerm && $association['site'] !== $term->locale()) { + continue; + } + + if (isset($this->publishedEntries[$association['entry']])) { + $entries[$association['entry']] = true; + } + } + + return count($entries); + } + + /** + * Whether a term's entries are counted by the method this index's own counting matches. + */ + private function countsEntriesTheUsualWay(): bool + { + return (new ReflectionMethod(Term::getFacadeRoot(), 'entriesCount'))->getDeclaringClass()->getName() === TermRepository::class; + } } diff --git a/tests/Stache/Indexes/TermEntriesCountTest.php b/tests/Stache/Indexes/TermEntriesCountTest.php new file mode 100644 index 00000000000..2ad92f2206b --- /dev/null +++ b/tests/Stache/Indexes/TermEntriesCountTest.php @@ -0,0 +1,132 @@ +store('tags')->index('entries_count'); + } + + private function createContent() + { + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en_US'], + 'fr' => ['url' => '/fr/', 'locale' => 'fr_FR'], + ]); + + Taxonomy::make('tags')->sites(['en', 'fr'])->save(); + Taxonomy::make('topics')->sites(['en', 'fr'])->save(); + Collection::make('blog')->sites(['en', 'fr'])->taxonomies(['tags', 'topics'])->save(); + Collection::make('news')->sites(['en'])->taxonomies(['tags'])->dated(true)->futureDateBehavior('private')->save(); + + EntryFactory::collection('blog')->id('1')->locale('en')->data(['tags' => ['alfa', 'bravo'], 'topics' => ['alfa']])->create(); + EntryFactory::collection('blog')->id('2')->locale('en')->data(['tags' => ['alfa']])->create(); + EntryFactory::collection('blog')->id('3')->locale('en')->data(['tags' => ['alfa', 'charlie']])->published(false)->create(); + EntryFactory::collection('blog')->id('4')->locale('fr')->data(['tags' => ['alfa', 'delta']])->create(); + EntryFactory::collection('blog')->id('5')->locale('fr')->data(['tags' => ['bravo']])->published(false)->create(); + EntryFactory::collection('news')->id('6')->locale('en')->date('2020-01-01')->data(['tags' => ['alfa', 'echo']])->create(); + EntryFactory::collection('news')->id('7')->locale('en')->date(now()->addYear()->format('Y-m-d'))->data(['tags' => ['bravo']])->create(); + + Term::make('alfa')->taxonomy('tags')->data(['title' => 'Alfa'])->save(); + Term::make('foxtrot')->taxonomy('tags')->data(['title' => 'Nothing uses this'])->save(); + } + + #[Test] + public function it_counts_what_each_term_would_count_for_itself() + { + $this->createContent(); + + $counts = $this->index()->items()->all(); + + $this->assertEquals([ + 'en::alfa' => 3, + 'en::bravo' => 1, + 'en::charlie' => 0, + 'en::echo' => 1, + 'en::foxtrot' => 0, + 'fr::alfa' => 1, + 'fr::bravo' => 0, + 'fr::delta' => 1, + 'fr::foxtrot' => 0, + ], collect($counts)->sortKeys()->all()); + + foreach ($counts as $key => $count) { + [$site, $slug] = explode('::', $key); + $term = Term::make($slug)->taxonomy('tags')->in($site); + + $this->assertSame(Term::entriesCount($term, 'published'), $count, $key); + } + } + + #[Test] + public function it_finds_which_entries_are_published_once_for_the_whole_index() + { + $this->createContent(); + + Stache::store('terms')->store('tags')->index('associations')->items(); + + $repository = new class(app('stache')) extends EntryRepository + { + public $queries = 0; + + public function query() + { + $this->queries++; + + return parent::query(); + } + }; + + Entry::swap($repository); + + $this->assertCount(9, $this->index()->items()); + $this->assertSame(1, $repository->queries); + } + + #[Test] + public function a_term_updated_by_itself_is_still_counted() + { + $this->createContent(); + $this->index()->items(); + + EntryFactory::collection('blog')->id('8')->locale('en')->data(['tags' => ['foxtrot']])->create(); + + $this->assertSame(1, $this->index()->items()->get('en::foxtrot')); + } + + #[Test] + public function a_repository_that_counts_entries_its_own_way_is_asked_for_each_term() + { + $this->createContent(); + + $repository = new class(app('stache')) extends TermRepository + { + public function entriesCount($term, ?string $status = null): int + { + return 99; + } + }; + + $this->app->instance(TermRepositoryContract::class, $repository); + Term::swap($repository); + + $this->assertEquals([99], collect($this->index()->getItems())->unique()->values()->all()); + } +}