diff --git a/src/Stache/Indexes/Terms/Associations.php b/src/Stache/Indexes/Terms/Associations.php index 1ddef3781e3..606c2a674fb 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,86 @@ 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) { + // 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; + } + + $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..8d95dade986 --- /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_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')); + } +}