From 69451289fc6f09a4a7edf4327d2665c67ecc85e4 Mon Sep 17 00:00:00 2001 From: Joshua Blum Date: Wed, 14 Oct 2026 17:56:24 +0200 Subject: [PATCH] Cache Entry::descendants() per request --- src/Entries/Entry.php | 68 ++++++++++------ tests/Data/Entries/EntryTest.php | 133 +++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 24 deletions(-) diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index 05e0458fe59..bdb436155e9 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -281,6 +281,8 @@ public function delete() Facades\Entry::delete($this); + $this->flushDescendantsCache($this); + if ($withEvents) { EntryDeleted::dispatch($this); } @@ -297,7 +299,7 @@ public function deleteDescendants($withEvents = true) $withEvents ? $entry->delete() : $entry->deleteQuietly(); }); - Blink::forget('entry-descendants-'.$this->id()); + $this->flushDescendantsCache($this); return true; } @@ -315,7 +317,7 @@ public function detachLocalizations() ->save(); }); - Blink::forget('entry-descendants-'.$this->id()); + $this->flushDescendantsCache($this); return true; } @@ -401,6 +403,8 @@ public function saveQuietly() public function save() { + $originalOrigin = $this->getOriginal('origin'); + $isNew = is_null(Facades\Entry::find($this->id())); $withEvents = $this->withEvents; @@ -440,7 +444,13 @@ public function save() Blink::store('collection-mounts')->flush(); } - $this->ancestors()->each(fn ($entry) => Blink::forget('entry-descendants-'.$entry->id())); + if ($origin = $this->origin()) { + $this->flushDescendantsCache($origin); + } + + if ($originalOrigin && $originalOrigin !== $origin?->id() && ($previousOrigin = Facades\Entry::find($originalOrigin))) { + $this->flushDescendantsCache($previousOrigin); + } if ($isNew && $this->collection()->orderable()) { // The entry only gets appended to the tree when it's read, so anything that @@ -867,7 +877,7 @@ public function ancestors() public function directDescendants() { - return Blink::once('entry-descendants-'.$this->id(), function () { + return clone Blink::once('entry-descendants-'.$this->id(), function () { return Facades\Entry::query() ->where('collection', $this->collectionHandle()) ->where('origin', $this->id())->get() @@ -877,31 +887,41 @@ public function directDescendants() public function descendants() { - $localizations = $this->directDescendants(); + return clone Blink::once('entry-all-descendants-'.$this->id(), function () { + $localizations = $this->directDescendants(); - // Breadth-first: fetch each level in one batched query instead of one query per node. - $origins = $localizations->map->id()->values()->all(); - $seen = array_merge($origins, [$this->id()]); + // Breadth-first: fetch each level in one batched query instead of one query per node. + $origins = $localizations->map->id()->values()->all(); + $seen = array_merge($origins, [$this->id()]); - while (! empty($origins)) { - $children = Facades\Entry::query() - ->where('collection', $this->collectionHandle()) - ->whereIn('origin', $origins) - ->get() - // Guard against cyclic or duplicate origin data, which would - // otherwise loop forever as the same entries reappear. - ->reject(fn ($entry) => in_array($entry->id(), $seen, true)); - - if ($children->isEmpty()) { - break; + while (! empty($origins)) { + $children = Facades\Entry::query() + ->where('collection', $this->collectionHandle()) + ->whereIn('origin', $origins) + ->get() + // Guard against cyclic or duplicate origin data, which would + // otherwise loop forever as the same entries reappear. + ->reject(fn ($entry) => in_array($entry->id(), $seen, true)); + + if ($children->isEmpty()) { + break; + } + + $localizations = $localizations->merge($children->keyBy->locale()); + $origins = $children->map->id()->values()->all(); + $seen = array_merge($seen, $origins); } - $localizations = $localizations->merge($children->keyBy->locale()); - $origins = $children->map->id()->values()->all(); - $seen = array_merge($seen, $origins); - } + return $localizations; + }); + } - return $localizations; + private function flushDescendantsCache($entry): void + { + $entry->ancestors()->prepend($entry)->each(function ($entry) { + Blink::forget('entry-descendants-'.$entry->id()); + Blink::forget('entry-all-descendants-'.$entry->id()); + }); } public function existsIn($locale) diff --git a/tests/Data/Entries/EntryTest.php b/tests/Data/Entries/EntryTest.php index 2ea53291037..cf631779a51 100644 --- a/tests/Data/Entries/EntryTest.php +++ b/tests/Data/Entries/EntryTest.php @@ -22,6 +22,7 @@ use Statamic\Entries\AugmentedEntry; use Statamic\Entries\Collection; use Statamic\Entries\Entry; +use Statamic\Entries\EntryCollection; use Statamic\Events\EntryBlueprintFound; use Statamic\Events\EntryCreated; use Statamic\Events\EntryCreating; @@ -1674,6 +1675,138 @@ public function it_includes_descendants_nested_via_an_origin_chain() $this->assertSame($de, $descendants->get('de')); } + #[Test] + public function it_only_queries_descendants_once_when_getting_every_localization() + { + $collection = (new Collection)->handle('pages')->save(); + + $entry = (new Entry)->id('a')->locale('en')->collection($collection); + $fr = (new Entry)->id('b')->locale('fr')->origin('a')->collection($collection); + $es = (new Entry)->id('c')->locale('es')->origin('a')->collection($collection); + $de = (new Entry)->id('d')->locale('de')->origin('b')->collection($collection); + + Facades\Entry::shouldReceive('query')->times(3)->andReturn( + $this->fakeDescendantsQuery(collect([$fr, $es])), + $this->fakeDescendantsQuery(collect([$de]), ['b', 'c']), + $this->fakeDescendantsQuery(collect(), ['d']), + ); + + $this->assertSame($entry, $entry->in('en')); + $this->assertSame($fr, $entry->in('fr')); + $this->assertSame($es, $entry->in('es')); + $this->assertSame($de, $entry->in('de')); + $this->assertNull($entry->in('it')); + $this->assertTrue($entry->existsIn('de')); + $this->assertEquals(['de', 'es', 'fr'], $entry->descendants()->keys()->sort()->values()->all()); + } + + #[Test] + public function modifying_the_returned_descendants_does_not_affect_the_cached_ones() + { + $this->setSites([ + 'en' => ['locale' => 'en_US', 'url' => '/'], + 'fr' => ['locale' => 'fr_FR', 'url' => '/fr/'], + ]); + + $en = EntryFactory::collection('test')->id('en')->locale('en')->create(); + $fr = EntryFactory::collection('test')->id('fr')->origin('en')->locale('fr')->create(); + + $en->descendants()->forget('fr'); + $en->directDescendants()->put('de', $fr); + + $this->assertInstanceOf(EntryCollection::class, $en->descendants()); + $this->assertEquals(['fr' => $fr], $en->descendants()->all()); + $this->assertEquals(['fr' => $fr], $en->directDescendants()->all()); + $this->assertSame($fr, $en->in('fr')); + } + + #[Test] + public function the_descendants_cache_is_cleared_when_localizations_are_added_or_deleted() + { + $this->setSites([ + 'en' => ['locale' => 'en_US', 'url' => '/'], + 'fr' => ['locale' => 'fr_FR', 'url' => '/fr/'], + 'de' => ['locale' => 'de_DE', 'url' => '/de/'], + ]); + + $en = EntryFactory::collection('test')->id('en')->locale('en')->create(); + $this->assertNull($en->in('fr')); + + $fr = tap($en->makeLocalization('fr')->id('fr'))->save(); + $this->assertEquals('fr', $en->in('fr')?->id()); + $this->assertNull($en->in('de')); + $this->assertEquals([], $fr->descendants()->all()); + + $de = tap($fr->makeLocalization('de')->id('de'))->save(); + $this->assertEquals('de', $en->in('de')?->id()); + $this->assertEquals('de', $fr->in('de')?->id()); + $this->assertEquals(['fr', 'de'], $en->descendants()->keys()->all()); + $this->assertEquals(['de'], $fr->descendants()->keys()->all()); + + $de->delete(); + $this->assertNull($en->in('de')); + $this->assertEquals(['fr'], $en->descendants()->keys()->all()); + $this->assertEquals([], $fr->descendants()->all()); + $this->assertEquals([], $fr->directDescendants()->all()); + } + + #[Test] + public function the_descendants_cache_is_cleared_when_detaching_localizations() + { + $this->setSites([ + 'en' => ['locale' => 'en_US', 'url' => '/'], + 'fr' => ['locale' => 'fr_FR', 'url' => '/fr/'], + 'de' => ['locale' => 'de_DE', 'url' => '/de/'], + ]); + + $en = EntryFactory::collection('test')->id('en')->locale('en')->create(); + $fr = EntryFactory::collection('test')->id('fr')->origin('en')->locale('fr')->create(); + EntryFactory::collection('test')->id('de')->origin('fr')->locale('de')->create(); + + $this->assertEquals('de', $en->in('de')?->id()); + $this->assertEquals(['de'], $fr->descendants()->keys()->all()); + + $fr->detachLocalizations(); + + $this->assertNull($en->in('de')); + $this->assertEquals(['fr'], $en->descendants()->keys()->all()); + $this->assertEquals([], $fr->descendants()->all()); + $this->assertNull(Facades\Entry::find('de')->in('en')); + } + + #[Test] + public function the_descendants_cache_is_cleared_for_the_previous_origin_when_the_origin_changes() + { + $this->setSites([ + 'en' => ['locale' => 'en_US', 'url' => '/'], + 'fr' => ['locale' => 'fr_FR', 'url' => '/fr/'], + 'es' => ['locale' => 'es_ES', 'url' => '/es/'], + 'de' => ['locale' => 'de_DE', 'url' => '/de/'], + ]); + + $en = EntryFactory::collection('test')->id('en')->locale('en')->create(); + $fr = EntryFactory::collection('test')->id('fr')->origin('en')->locale('fr')->create(); + $es = EntryFactory::collection('test')->id('es')->origin('en')->locale('es')->create(); + $de = EntryFactory::collection('test')->id('de')->origin('fr')->locale('de')->create(); + + $this->assertEquals(['de'], $fr->descendants()->keys()->all()); + $this->assertEquals(['de'], $fr->directDescendants()->keys()->all()); + $this->assertEquals([], $es->descendants()->all()); + + $de->origin('es')->save(); + + $this->assertEquals([], $fr->descendants()->all()); + $this->assertEquals([], $fr->directDescendants()->all()); + $this->assertEquals(['de'], $es->descendants()->keys()->all()); + $this->assertEquals('de', $en->in('de')?->id()); + + $de->origin(null)->save(); + + $this->assertNull($en->in('de')); + $this->assertEquals([], $es->descendants()->all()); + $this->assertEquals(['fr', 'es'], $en->descendants()->keys()->all()); + } + private function fakeDescendantsQuery($results, ?array $whereInOrigins = null): QueryBuilder { $query = Mockery::mock(QueryBuilder::class);