Skip to content
Open
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
6 changes: 0 additions & 6 deletions .phpstan/baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 29 additions & 14 deletions src/Stache/Stores/Store.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,34 +217,45 @@ 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.
if ($modified->isEmpty() && $deleted->isEmpty()) {
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 {
Expand Down Expand Up @@ -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;
}

Expand Down
91 changes: 89 additions & 2 deletions tests/Stache/BasicStoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -134,23 +199,45 @@ 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);
}
}

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;
}
}
34 changes: 34 additions & 0 deletions tests/Stache/Stores/EntriesStoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
32 changes: 32 additions & 0 deletions tests/Stache/Stores/TermsStoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Loading