diff --git a/.phpstan/baseline.neon b/.phpstan/baseline.neon index b1f8dad6fab..74817c2f7f8 100644 --- a/.phpstan/baseline.neon +++ b/.phpstan/baseline.neon @@ -204,12 +204,6 @@ parameters: count: 2 path: ../src/Stache/Stores/Store.php - - - message: '#^Call to an undefined method Statamic\\Stache\\Stores\\Store\:\:getKeyFromPath\(\)\.$#' - identifier: method.notFound - count: 1 - path: ../src/Stache/Stores/Store.php - - message: '#^Call to an undefined method Statamic\\Stache\\Stores\\Store\:\:makeItemFromFile\(\)\.$#' identifier: method.notFound diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index ee31e4236a9..a6c2d856f40 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -217,12 +217,13 @@ public function handleFileChanges() return $existingTimestamp < $timestamp; }); - // Get all the deleted files. + // Get all the deleted files, with the keys each one had. // This would be any paths that exist in the cached array that aren't there anymore. - $keys = $this->paths()->flip(); + // One file can hold several items (e.g. a term and its localizations), so a path maps to a list of keys. + $keys = $this->paths()->mapToGroups(fn ($path, $key) => [$path => $key]); $deleted = $existing->keys() - ->map(fn ($path) => $keys->get($path)) - ->diff($files->keys()->map(fn ($path) => $keys->get($path))) + ->diff($files->keys()) + ->flatMap(fn ($path) => $keys->get($path, collect())->map(fn ($key) => [$path, $key])) ->values(); // If there are no modified or deleted files, there's nothing to update. @@ -230,21 +231,31 @@ public function handleFileChanges() return; } - // Flush cached instances of deleted items. - $deleted->each(function ($path) { - collect($this->getKeyFromPath($path))->each(function ($key) use ($path) { - $this->forgetItem($key); - $this->forgetPath($key); - $this->resolveIndexes()->filter->isCached()->each->forgetItem($key); - $this->handleDeletedItem($path, $key); - }); - }); - // Get items from every file that was modified. $modified = $modified->flatMap(function ($timestamp, $path) { return Arr::wrap($this->getItemFromModifiedPath($path)); }); + // A file that was renamed or moved shows up as a deleted path plus a modified one with + // the same key. That item still exists, so only its old path is released here. It'll be + // re-added under its new path along with the other modified items below. + $modifiedKeys = $modified->map(fn ($item) => $this->getItemKey($item))->flip(); + + $deleted->each(function ($deletion) use ($modifiedKeys) { + [$path, $key] = $deletion; + if ($modifiedKeys->has($key)) { + $this->keys()->forget($key); + + return; + } + + // Flush cached instances of deleted items. + $this->forgetItem($key); + $this->forgetPath($key); + $this->resolveIndexes()->filter->isCached()->each->forgetItem($key); + $this->handleDeletedItem($path, $key); + }); + // Remove items with duplicate IDs/keys $modified = $modified->reject(function ($item) { try { @@ -280,6 +291,10 @@ public function handleFileChanges() }); }); + // The paths were memoized before the changes above were applied to the path index, + // so drop them; the next call reads the updated ones (with added and renamed files). + $this->paths = null; + $this->modified = $modified; } diff --git a/tests/Stache/BasicStoreTest.php b/tests/Stache/BasicStoreTest.php index abaabbe526f..9475ac9face 100644 --- a/tests/Stache/BasicStoreTest.php +++ b/tests/Stache/BasicStoreTest.php @@ -115,6 +115,71 @@ public function rebuilding_paths_caches_file_timestamps_for_the_watcher() $this->assertEquals([$this->tempDir.'/foo.yaml' => 1234567890], Cache::get('stache::timestamps::test')); } + + #[Test] + public function the_watcher_removes_an_item_whose_file_was_deleted() + { + config(['statamic.stache.watcher' => true]); + + file_put_contents($this->tempDir.'/foo.yaml', ''); + file_put_contents($this->tempDir.'/bar.yaml', ''); + $this->store->paths(); + + unlink($this->tempDir.'/bar.yaml'); + $store = $this->nextRequest(); + + $this->assertNull($store->getItem('bar')); + $this->assertEquals(['foo'], $store->paths()->keys()->all()); + $this->assertEquals(['foo'], $store->index('path')->keys()); + } + + #[Test] + public function the_watcher_keeps_an_item_whose_file_was_renamed() + { + config(['statamic.stache.watcher' => true]); + + $this->store = (new TestBasicStoreWithIds)->directory($this->tempDir); + file_put_contents($this->tempDir.'/a.yaml', "id: one\ntitle: First"); + $this->store->paths(); + + rename($this->tempDir.'/a.yaml', $this->tempDir.'/b.yaml'); + $store = $this->nextRequest(); + + $this->assertNotNull($item = $store->getItem('one')); + $this->assertEquals('First', $item->data()['title']); + $this->assertEquals(['one' => $this->tempDir.'/b.yaml'], $store->paths()->all()); + } + + #[Test] + public function the_watcher_handles_a_deleted_and_an_added_file_in_the_same_request() + { + config(['statamic.stache.watcher' => true]); + + file_put_contents($this->tempDir.'/foo.yaml', ''); + file_put_contents($this->tempDir.'/bar.yaml', ''); + $this->store->paths(); + + unlink($this->tempDir.'/bar.yaml'); + file_put_contents($this->tempDir.'/baz.yaml', ''); + $store = $this->nextRequest(); + + $this->assertNull($store->getItem('bar')); + $this->assertNotNull($store->getItem('baz')); + $this->assertEquals(['baz', 'foo'], $store->paths()->keys()->sort()->values()->all()); + } + + /** + * A new store instance with nothing memoized, as the next request would get. + * The cache (where the Stache keeps its data between requests) is untouched. + */ + private function nextRequest() + { + $class = get_class($this->store); + app('stache.indexes')->each->resetMemoizedState(); + app()->forgetInstance('stache.indexes'); + + return $this->store = (new $class)->directory($this->tempDir); + } } class TestBasicStore extends BasicStore @@ -134,7 +199,17 @@ public function makeItemFromFile($path, $contents) $id = Str::after($path, Path::tidy(__DIR__.'/tmp/')); $id = Str::before($id, '.yaml'); - return new TestBasicStoreItem($id, $data); + return new TestBasicStoreItem($id, $data, $path); + } +} + +class TestBasicStoreWithIds extends TestBasicStore +{ + public function makeItemFromFile($path, $contents) + { + $data = YAML::parse($contents); + + return new TestBasicStoreItem($data['id'], $data, $path); } } @@ -142,15 +217,27 @@ class TestBasicStoreItem { private $id; private $data; + private $path; - public function __construct($id, $data) + public function __construct($id, $data, $path = null) { $this->id = $id; $this->data = $data; + $this->path = $path; } public function id() { return $this->id; } + + public function data() + { + return $this->data; + } + + public function path() + { + return $this->path; + } } diff --git a/tests/Stache/Stores/EntriesStoreTest.php b/tests/Stache/Stores/EntriesStoreTest.php index 705a70f3c75..75e147a4595 100644 --- a/tests/Stache/Stores/EntriesStoreTest.php +++ b/tests/Stache/Stores/EntriesStoreTest.php @@ -35,6 +35,40 @@ public function setUp(): void Stache::store('collections')->directory($this->directory); } + #[Test] + public function the_watcher_removes_an_entry_whose_file_was_deleted() + { + config(['statamic.stache.watcher' => true]); + + $path = $this->directory.'/blog/2026-01-01.watcher-test.md'; + file_put_contents($path, "---\nid: watcher-test\ntitle: 'Watcher test'\n---\n"); + + try { + $this->assertNotNull(Facades\Entry::find('watcher-test')); + + unlink($path); + $this->nextRequest(); + + $this->assertNull(Facades\Entry::find('watcher-test')); + $this->assertNotContains('watcher-test', Facades\Entry::query()->where('collection', 'blog')->get()->map->id()->all()); + } finally { + @unlink($path); + } + } + + /** + * Fresh stores and indexes with nothing memoized, as the next request would get. + * The cache (where the Stache keeps its data between requests) is untouched. + */ + private function nextRequest() + { + app('stache.indexes')->each->resetMemoizedState(); + app()->forgetInstance('stache.indexes'); + + $this->parent = (new EntriesStore)->directory($this->directory); + Stache::registerStore($this->parent); + } + #[Test] public function it_gets_nested_files() { diff --git a/tests/Stache/Stores/TermsStoreTest.php b/tests/Stache/Stores/TermsStoreTest.php index 09a8168d84f..57e4734431a 100644 --- a/tests/Stache/Stores/TermsStoreTest.php +++ b/tests/Stache/Stores/TermsStoreTest.php @@ -44,4 +44,36 @@ public function it_saves_to_disk() $this->assertEquals($path, $this->parent->store('tags')->paths()->get('en::test')); } + + #[Test] + public function the_watcher_removes_every_localization_of_a_term_whose_file_was_deleted() + { + config(['statamic.stache.watcher' => true]); + + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en_US'], + 'fr' => ['url' => '/fr/', 'locale' => 'fr_FR'], + ]); + $taxonomyFile = $this->directory.'/tags.yaml'; + $taxonomyYaml = file_get_contents($taxonomyFile); + Facades\Taxonomy::make('tags')->sites(['en', 'fr'])->save(); + + $path = $this->directory.'/tags/watcher-test.yaml'; + file_put_contents($path, "title: 'Watcher test'\nlocalizations:\n fr:\n title: 'Test du watcher'\n"); + + try { + $store = $this->parent->store('tags'); + $this->assertEquals($path, $store->paths()->get('en::watcher-test')); + $this->assertEquals($path, $store->paths()->get('fr::watcher-test')); + + unlink($path); + $store->handleFileChanges(); + + $this->assertNull($store->paths()->get('en::watcher-test')); + $this->assertNull($store->paths()->get('fr::watcher-test')); + } finally { + @unlink($path); + file_put_contents($taxonomyFile, $taxonomyYaml); + } + } }