Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions src/Stache/Indexes/Terms/Associations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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) {
Expand Down
14 changes: 6 additions & 8 deletions src/Stache/Query/QueriesTaxonomizedEntries.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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();
});
}
}
8 changes: 2 additions & 6 deletions src/Stache/Query/TermQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down
5 changes: 2 additions & 3 deletions src/Stache/Repositories/TermRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
138 changes: 138 additions & 0 deletions tests/Stache/Indexes/TermAssociationsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

namespace Tests\Stache\Indexes;

use Facades\Tests\Factories\EntryFactory;
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\Collection;
use Statamic\Facades\Entry;
use Statamic\Facades\Stache;
use Statamic\Facades\Taxonomy;
use Statamic\Facades\Term;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;

class TermAssociationsTest extends TestCase
{
use PreventSavingStacheItemsToDisk;

private function associations()
{
return Stache::store('terms')->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'));
}
}
Loading