diff --git a/src/Stache/Stores/TaxonomyTermsStore.php b/src/Stache/Stores/TaxonomyTermsStore.php index 1d3081d0afd..70842f1054e 100644 --- a/src/Stache/Stores/TaxonomyTermsStore.php +++ b/src/Stache/Stores/TaxonomyTermsStore.php @@ -73,20 +73,30 @@ public function getItem($key) $this->handleFileChanges(); if ($item = $this->getCachedItem($key)) { + // Items cached by an older version won't have had their original state synced when + // they were hydrated, and would never get one. We only backfill it when it's + // missing, since re-syncing would discard changes made to a term being saved. + if (empty($item->term()->getOriginal())) { + $item->term()->syncOriginal(); + } + return $item; } [$site, $slug] = explode('::', $key); if ($path = $this->getPath($key)) { - $item = $this->makeItemFromFile($path, File::get($path))->in($site); + $term = $this->makeItemFromFile($path, File::get($path)); } else { - $item = Term::make($slug) + $term = Term::make($slug) ->taxonomy($this->childKey()) - ->set('title', $this->index('title')->get($key)) - ->in($site); + ->set('title', $this->index('title')->get($key)); } + $term->syncOriginal(); + + $item = $term->in($site); + $this->cacheItem($item); return $item; diff --git a/tests/Listeners/UpdateTermReferencesTest.php b/tests/Listeners/UpdateTermReferencesTest.php index 11f98d0a9ce..3aa9874ef21 100644 --- a/tests/Listeners/UpdateTermReferencesTest.php +++ b/tests/Listeners/UpdateTermReferencesTest.php @@ -2,8 +2,10 @@ namespace Tests\Listeners; +use Illuminate\Support\Facades\Event; use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\Test; +use Statamic\Events\TermSaving; use Statamic\Facades; use Statamic\Support\Arr; use Tests\PreventSavingStacheItemsToDisk; @@ -314,6 +316,151 @@ public function it_updates_scoped_term_fields_regardless_of_max_items_setting() $this->assertEquals('topics::norris', $entry->fresh()->get('non_favourites')); } + #[Test] + public function it_nullifies_references_when_deleting_a_term_loaded_from_its_file() + { + $collection = tap(Facades\Collection::make('articles'))->save(); + + $this->setInBlueprints('collections/articles', [ + 'fields' => [ + [ + 'handle' => 'favourites', + 'field' => [ + 'type' => 'terms', + 'taxonomies' => ['topics'], + 'mode' => 'select', + ], + ], + ], + ]); + + $entry = tap(Facades\Entry::make()->collection($collection)->data([ + 'favourites' => ['hoff', 'norris'], + ]))->save(); + + $this->assertEquals(['hoff', 'norris'], $entry->get('favourites')); + + Facades\Stache::store('terms')->store('topics')->forgetItem('en::hoff'); + + Facades\Term::find('topics::hoff')->delete(); + + $this->assertEquals(['norris'], $entry->fresh()->get('favourites')); + } + + /** @see https://github.com/statamic/cms/issues/11264 */ + #[Test] + public function it_nullifies_references_when_deleting_a_term_that_only_exists_in_entry_data() + { + $collection = tap(Facades\Collection::make('articles')->taxonomies(['topics']))->save(); + + $this->setInBlueprints('collections/articles', [ + 'fields' => [ + [ + 'handle' => 'topics', + 'field' => [ + 'type' => 'terms', + 'taxonomies' => ['topics'], + 'mode' => 'select', + ], + ], + ], + ]); + + $entry = tap(Facades\Entry::make()->collection($collection)->data([ + 'topics' => ['hoff', 'ghost'], + ]))->save(); + + $this->assertEquals(['hoff', 'ghost'], $entry->get('topics')); + + Facades\Term::find('topics::ghost')->delete(); + + $this->assertEquals(['hoff'], $entry->fresh()->get('topics')); + $this->assertNull(Facades\Term::find('topics::ghost')); + } + + #[Test] + public function it_updates_references_when_renaming_a_term_retrieved_from_the_stache() + { + $collection = tap(Facades\Collection::make('articles'))->save(); + + $this->setInBlueprints('collections/articles', [ + 'fields' => [ + [ + 'handle' => 'favourite', + 'field' => [ + 'type' => 'terms', + 'taxonomies' => ['topics'], + 'max_items' => 1, + 'mode' => 'select', + ], + ], + ], + ]); + + $entry = tap(Facades\Entry::make()->collection($collection)->data([ + 'favourite' => 'hoff', + ]))->save(); + + $term = Facades\Term::find('topics::hoff'); + $term->slug('hoff-new'); + $term->save(); + + $this->assertEquals('hoff-new', $entry->fresh()->get('favourite')); + } + + #[Test] + public function it_nullifies_references_when_deleting_a_term_cached_without_its_original_state() + { + $collection = tap(Facades\Collection::make('articles'))->save(); + + $this->setInBlueprints('collections/articles', [ + 'fields' => [ + [ + 'handle' => 'favourites', + 'field' => [ + 'type' => 'terms', + 'taxonomies' => ['topics'], + 'mode' => 'select', + ], + ], + ], + ]); + + $entry = tap(Facades\Entry::make()->collection($collection)->data([ + 'favourites' => ['hoff', 'norris'], + ]))->save(); + + // Mimic an item cached by a version that didn't sync the original state. + $store = Facades\Stache::store('terms')->store('topics'); + $item = Facades\Term::find('topics::hoff'); + (function () { + $this->original = []; + })->call($item->term()); + (function () use ($item) { + $this->cacheItem($item); + })->call($store); + + Facades\Term::find('topics::hoff')->delete(); + + $this->assertEquals(['norris'], $entry->fresh()->get('favourites')); + } + + #[Test] + public function it_keeps_a_term_retrieved_from_the_stache_dirty_until_it_has_been_saved() + { + $dirty = null; + + Event::listen(TermSaving::class, function ($event) use (&$dirty) { + $dirty = $event->term->isDirty('title'); + }); + + $term = Facades\Term::find('topics::hoff'); + $term->set('title', 'The Hoff'); + $term->save(); + + $this->assertTrue($dirty); + } + #[Test] public function it_nullifies_references_when_deleting_a_scoped_term() {