diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index c8315f48ef4..07ba57ffd2e 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -869,8 +869,26 @@ public function descendants() { $localizations = $this->directDescendants(); - foreach ($localizations as $loc) { - $localizations = $localizations->merge($loc->descendants()); + // 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; + } + + $localizations = $localizations->merge($children->keyBy->locale()); + $origins = $children->map->id()->values()->all(); + $seen = array_merge($seen, $origins); } return $localizations; diff --git a/tests/Data/Entries/EntryTest.php b/tests/Data/Entries/EntryTest.php index e93fce2ddbc..cb40c9c2352 100644 --- a/tests/Data/Entries/EntryTest.php +++ b/tests/Data/Entries/EntryTest.php @@ -1577,6 +1577,74 @@ public function it_propagates_entry_if_configured() }); } + #[Test] + public function it_resolves_descendants_with_one_query_per_level_rather_than_one_per_localization() + { + $collection = (new Collection)->handle('pages')->save(); + + $entry = (new Entry)->id('a')->locale('en')->collection($collection); + + // Three direct localizations, all leaves (no further descendants). + $fr = (new Entry)->id('b')->locale('fr')->origin('a')->collection($collection); + $de = (new Entry)->id('c')->locale('de')->origin('a')->collection($collection); + $es = (new Entry)->id('d')->locale('es')->origin('a')->collection($collection); + + // A flat tree is one level deep, so it should take exactly two queries + // (the direct descendants, then a single batched lookup for the next + // level which comes back empty) regardless of how many localizations + // exist. The previous recursive implementation issued one query per + // node, i.e. O(number of localizations). + Facades\Entry::shouldReceive('query')->times(2)->andReturn( + $this->fakeDescendantsQuery(collect([$fr, $de, $es])), // direct descendants of 'a' + $this->fakeDescendantsQuery(collect(), ['b', 'c', 'd']), // batched lookup for the next level + ); + + $descendants = $entry->descendants(); + + $this->assertEquals(['de', 'es', 'fr'], $descendants->keys()->sort()->values()->all()); + } + + #[Test] + public function it_includes_descendants_nested_via_an_origin_chain() + { + $collection = (new Collection)->handle('pages')->save(); + + $entry = (new Entry)->id('a')->locale('en')->collection($collection); + + // de's origin is fr, whose origin is en: a grandchild only reachable by + // walking past the first level. The flattened result must include it. + $fr = (new Entry)->id('b')->locale('fr')->origin('a')->collection($collection); + $de = (new Entry)->id('c')->locale('de')->origin('b')->collection($collection); + + Facades\Entry::shouldReceive('query')->times(3)->andReturn( + $this->fakeDescendantsQuery(collect([$fr])), // direct descendants of en + $this->fakeDescendantsQuery(collect([$de]), ['b']), // batched descendants of fr + $this->fakeDescendantsQuery(collect(), ['c']), // batched descendants of de + ); + + $descendants = $entry->descendants(); + + $this->assertEquals(['de', 'fr'], $descendants->keys()->sort()->values()->all()); + $this->assertSame($fr, $descendants->get('fr')); + $this->assertSame($de, $descendants->get('de')); + } + + private function fakeDescendantsQuery($results, ?array $whereInOrigins = null): QueryBuilder + { + $query = Mockery::mock(QueryBuilder::class); + $query->shouldReceive('where')->with('collection', 'pages')->andReturnSelf(); + + if ($whereInOrigins === null) { + // directDescendants() uses where('origin', string); batched levels use whereIn. + $query->shouldReceive('where')->with('origin', Mockery::type('string'))->andReturnSelf(); + } + + $query->shouldReceive('whereIn')->with('origin', $whereInOrigins ?? Mockery::type('array'))->andReturnSelf(); + $query->shouldReceive('get')->andReturn($results); + + return $query; + } + #[Test] public function it_doesnt_fire_events_when_propagating_entry_and_saved_quietly() {