From 6f8ec04f2191703a4b8faa124eec0ce6c707949c Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Wed, 11 Nov 2026 00:51:20 +0000 Subject: [PATCH 1/3] Look term associations up instead of reading through all of them Every taxonomy's associations index holds one row for each term an entry uses. Finding a term's entries, counting them, or finding the terms a collection uses read through every row each time, so a page that lists terms or filters entries by one did that several times over, and the cost grew with the number of entries. The index now groups its rows by slug, by value and by collection the first time one of those is asked for, and looks them up after that. The grouping is made again whenever the rows are no longer the ones it was made from, so saving or deleting an entry is seen straight away. What's stored in the cache is unchanged. --- src/Stache/Indexes/Terms/Associations.php | 77 ++++++++++ .../Query/QueriesTaxonomizedEntries.php | 14 +- src/Stache/Query/TermQueryBuilder.php | 8 +- src/Stache/Repositories/TermRepository.php | 5 +- tests/Stache/Indexes/TermAssociationsTest.php | 138 ++++++++++++++++++ 5 files changed, 225 insertions(+), 17 deletions(-) create mode 100644 tests/Stache/Indexes/TermAssociationsTest.php diff --git a/src/Stache/Indexes/Terms/Associations.php b/src/Stache/Indexes/Terms/Associations.php index 1ddef3781e3..00653228985 100644 --- a/src/Stache/Indexes/Terms/Associations.php +++ b/src/Stache/Indexes/Terms/Associations.php @@ -8,6 +8,9 @@ class Associations extends Index { + private ?array $grouped = null; + private ?array $groupedFrom = null; + public function getItems() { return Taxonomy::findByHandle($handle = $this->store->childKey()) @@ -31,6 +34,80 @@ public function getItems() })->all(); } + /** + * The associations for a term, found by its slug. + */ + public function forSlug($slug): array + { + return $this->grouped()['slug'][(string) $slug] ?? []; + } + + /** + * The associations for any of the given term slugs, in the order they are stored. + */ + public function forSlugs(array $slugs): array + { + $associations = []; + + foreach (array_unique(array_map('strval', $slugs)) as $slug) { + $associations += $this->forSlug($slug); + } + + ksort($associations); + + return $associations; + } + + /** + * The associations for a term, found by the value entries store for it. + */ + public function forValue($value): array + { + return $this->grouped()['value'][(string) $value] ?? []; + } + + /** + * The slugs of the terms that entries in any of the given collections use. + */ + public function slugsInCollections(array $collections): array + { + $slugs = []; + + foreach ($collections as $collection) { + $slugs += $this->grouped()['collection'][(string) $collection] ?? []; + } + + return array_map('strval', array_keys($slugs)); + } + + /** + * Every query for a term's entries, or for the terms a collection uses, + * would otherwise read through every association of the taxonomy. The + * associations are grouped once and looked up after that. The grouping + * is thrown away as soon as the associations are no longer the ones it + * was made from, however they came to change. + */ + private function grouped(): array + { + $this->load(); + + if ($this->grouped !== null && $this->groupedFrom === $this->items) { + return $this->grouped; + } + + $grouped = ['slug' => [], 'value' => [], 'collection' => []]; + + foreach ($this->items ?? [] as $i => $association) { + $grouped['slug'][(string) $association['slug']][$i] = $association; + $grouped['value'][(string) $association['value']][$i] = $association; + $grouped['collection'][(string) $association['collection']][(string) $association['slug']] = true; + } + + $this->groupedFrom = $this->items; + + return $this->grouped = $grouped; + } + public function forgetEntry($id) { $this->items = $this->items()->reject(function ($association) use ($id) { diff --git a/src/Stache/Query/QueriesTaxonomizedEntries.php b/src/Stache/Query/QueriesTaxonomizedEntries.php index 8bfc66ce122..5299e958396 100644 --- a/src/Stache/Query/QueriesTaxonomizedEntries.php +++ b/src/Stache/Query/QueriesTaxonomizedEntries.php @@ -80,10 +80,9 @@ private function getKeysForTaxonomyWhereBasic($where) [$taxonomy, $slug] = explode('::', $term); - return Stache::store('terms')->store($taxonomy) - ->index('associations') - ->items()->where('slug', $slug) - ->pluck('entry'); + return collect(Stache::store('terms')->store($taxonomy)->index('associations')->forSlug($slug)) + ->pluck('entry') + ->values(); } private function getKeysForTaxonomyWhereIn($where) @@ -103,10 +102,9 @@ private function getKeysForTaxonomyWhereIn($where) }); return $taxonomies->flatMap(function ($terms, $taxonomy) { - return Stache::store('terms')->store($taxonomy) - ->index('associations') - ->items()->whereIn('slug', $terms->all()) - ->pluck('entry'); + return collect(Stache::store('terms')->store($taxonomy)->index('associations')->forSlugs($terms->all())) + ->pluck('entry') + ->values(); }); } } diff --git a/src/Stache/Query/TermQueryBuilder.php b/src/Stache/Query/TermQueryBuilder.php index e02bdf86d2b..ea125c49912 100644 --- a/src/Stache/Query/TermQueryBuilder.php +++ b/src/Stache/Query/TermQueryBuilder.php @@ -152,12 +152,8 @@ public function get($columns = ['*']) private function filterUsagesWithinCollections($taxonomies) { $this->whereIn('id', collect($taxonomies)->flatMap(function ($taxonomy) { - return $this->store->store($taxonomy) - ->index('associations') - ->items()->whereIn('collection', $this->collections) - ->map(function ($item) use ($taxonomy) { - return $taxonomy.'::'.$item['slug']; - }); + return collect($this->store->store($taxonomy)->index('associations')->slugsInCollections($this->collections)) + ->map(fn ($slug) => $taxonomy.'::'.$slug); })->all()); } diff --git a/src/Stache/Repositories/TermRepository.php b/src/Stache/Repositories/TermRepository.php index b7069bd010b..5b16cb2095e 100644 --- a/src/Stache/Repositories/TermRepository.php +++ b/src/Stache/Repositories/TermRepository.php @@ -164,10 +164,9 @@ public function make(?string $slug = null): Term public function entriesCount(Term $term, ?string $status = null): int { - $items = $this->store->store($term->taxonomyHandle()) + $items = collect($this->store->store($term->taxonomyHandle()) ->index('associations') - ->items() - ->where('value', $term->inDefaultLocale()->slug()); + ->forValue($term->inDefaultLocale()->slug())); if ($term instanceof LocalizedTerm) { $items = $items->where('site', $term->locale()); diff --git a/tests/Stache/Indexes/TermAssociationsTest.php b/tests/Stache/Indexes/TermAssociationsTest.php new file mode 100644 index 00000000000..cf5c12f4cf4 --- /dev/null +++ b/tests/Stache/Indexes/TermAssociationsTest.php @@ -0,0 +1,138 @@ +store('tags')->index('associations'); + } + + private function createEntries() + { + Taxonomy::make('tags')->save(); + Collection::make('blog')->taxonomies(['tags'])->save(); + Collection::make('news')->taxonomies(['tags'])->save(); + + EntryFactory::collection('blog')->id('1')->data(['tags' => ['Alfa', 'bravo']])->create(); + EntryFactory::collection('blog')->id('2')->data(['tags' => ['bravo']])->create(); + EntryFactory::collection('news')->id('3')->data(['tags' => ['Alfa', 'charlie']])->create(); + } + + #[Test] + public function it_finds_associations_by_slug_value_and_collection() + { + $this->createEntries(); + + $index = $this->associations(); + + $this->assertEquals(['1', '3'], collect($index->forSlug('alfa'))->pluck('entry')->sort()->values()->all()); + $this->assertEquals(['1', '2'], collect($index->forSlug('bravo'))->pluck('entry')->sort()->values()->all()); + $this->assertSame([], $index->forSlug('delta')); + + // Entries store the value as it was typed; the slug is what a term is found by. + $this->assertEquals(['1', '3'], collect($index->forValue('Alfa'))->pluck('entry')->sort()->values()->all()); + $this->assertSame([], $index->forValue('alfa')); + + $this->assertEqualsCanonicalizing(['alfa', 'bravo'], $index->slugsInCollections(['blog'])); + $this->assertEqualsCanonicalizing(['alfa', 'charlie'], $index->slugsInCollections(['news'])); + $this->assertEqualsCanonicalizing(['alfa', 'bravo', 'charlie'], $index->slugsInCollections(['blog', 'news'])); + $this->assertSame([], $index->slugsInCollections(['pages'])); + } + + #[Test] + public function associations_for_several_slugs_come_back_in_the_order_they_are_stored() + { + $this->createEntries(); + + $index = $this->associations(); + + $expected = $index->items()->whereIn('slug', ['charlie', 'alfa'])->all(); + + $this->assertSame($expected, $index->forSlugs(['charlie', 'alfa'])); + $this->assertSame($expected, $index->forSlugs(['alfa', 'charlie', 'alfa'])); + $this->assertSame(array_keys($expected), array_keys($index->forSlugs(['alfa', 'charlie']))); + } + + #[Test] + public function it_gives_what_reading_through_every_association_gives() + { + $this->createEntries(); + + $index = $this->associations(); + + foreach (['alfa', 'bravo', 'charlie', 'delta'] as $slug) { + $this->assertSame($index->items()->where('slug', $slug)->all(), $index->forSlug($slug)); + } + + foreach (['Alfa', 'bravo', 'charlie', 'alfa'] as $value) { + $this->assertSame($index->items()->where('value', $value)->all(), $index->forValue($value)); + } + + foreach ([['blog'], ['news'], ['blog', 'news'], ['pages']] as $collections) { + $this->assertEqualsCanonicalizing( + $index->items()->whereIn('collection', $collections)->pluck('slug')->unique()->values()->all(), + $index->slugsInCollections($collections) + ); + } + } + + #[Test] + public function saving_and_deleting_entries_is_seen_straight_away() + { + $this->createEntries(); + + $index = $this->associations(); + $this->assertCount(2, $index->forSlug('alfa')); + $this->assertSame([], $index->forSlug('delta')); + + // A new entry using an existing term and a new one. + EntryFactory::collection('news')->id('4')->data(['tags' => ['alfa', 'delta']])->create(); + $this->assertEquals(['1', '3', '4'], collect($index->forSlug('alfa'))->pluck('entry')->sort()->values()->all()); + $this->assertEquals(['4'], collect($index->forSlug('delta'))->pluck('entry')->all()); + $this->assertContains('delta', $index->slugsInCollections(['news'])); + + // An entry that stops using a term. + Entry::find('1')->set('tags', ['bravo'])->save(); + $this->assertEquals(['3', '4'], collect($index->forSlug('alfa'))->pluck('entry')->sort()->values()->all()); + $this->assertEqualsCanonicalizing(['bravo'], $index->slugsInCollections(['blog'])); + + // The queries built on the index agree. + $this->assertEquals(['3', '4'], Entry::query()->whereTaxonomy('tags::alfa')->get()->map->id()->sort()->values()->all()); + $this->assertSame(2, Term::entriesCount(Term::find('tags::bravo'))); + $this->assertEquals(['tags::bravo'], Term::query()->where('collection', 'blog')->get()->map->id()->all()); + } + + #[Test] + public function an_index_that_is_cleared_or_changed_directly_is_grouped_again() + { + $this->createEntries(); + + $index = $this->associations(); + $this->assertCount(2, $index->forSlug('alfa')); + + $index->push(['value' => 'alfa', 'slug' => 'alfa', 'entry' => '9', 'collection' => 'pages', 'site' => 'en']); + $this->assertCount(3, $index->forSlug('alfa')); + $this->assertSame(['alfa'], $index->slugsInCollections(['pages'])); + + $index->forgetEntry('9'); + $this->assertCount(2, $index->forSlug('alfa')); + $this->assertSame([], $index->slugsInCollections(['pages'])); + + $index->clear(); + $this->assertCount(2, $index->forSlug('alfa')); + } +} From 1dd0b96786b2e74a6b7d376bd1847a1437b4d265 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Wed, 11 Nov 2026 03:20:17 +0000 Subject: [PATCH 2/3] Name the test for what it covers It checks that saves are seen straight away. Deleting an entry doesn't update a taxonomy's associations, which is how it already works. --- tests/Stache/Indexes/TermAssociationsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Stache/Indexes/TermAssociationsTest.php b/tests/Stache/Indexes/TermAssociationsTest.php index cf5c12f4cf4..8d95dade986 100644 --- a/tests/Stache/Indexes/TermAssociationsTest.php +++ b/tests/Stache/Indexes/TermAssociationsTest.php @@ -91,7 +91,7 @@ public function it_gives_what_reading_through_every_association_gives() } #[Test] - public function saving_and_deleting_entries_is_seen_straight_away() + public function saving_entries_is_seen_straight_away() { $this->createEntries(); From 1e83a4057417bc67355e3146010bcae2d43acf5c Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Wed, 11 Nov 2026 17:06:38 +0000 Subject: [PATCH 3/3] Keep the reloaded associations to compare against --- src/Stache/Indexes/Terms/Associations.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Stache/Indexes/Terms/Associations.php b/src/Stache/Indexes/Terms/Associations.php index 00653228985..606c2a674fb 100644 --- a/src/Stache/Indexes/Terms/Associations.php +++ b/src/Stache/Indexes/Terms/Associations.php @@ -92,6 +92,12 @@ private function grouped(): array $this->load(); if ($this->grouped !== null && $this->groupedFrom === $this->items) { + // Associations that are loaded again (between queued jobs, say) come back + // as a different array with the same contents. Holding on to that one + // means the next comparison is of an array with itself, which is instant, + // rather than of every association in it. + $this->groupedFrom = $this->items; + return $this->grouped; }