Skip to content
Merged
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
22 changes: 20 additions & 2 deletions src/Entries/Entry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
68 changes: 68 additions & 0 deletions tests/Data/Entries/EntryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +1634 to +1643

return $query;
}

#[Test]
public function it_doesnt_fire_events_when_propagating_entry_and_saved_quietly()
{
Expand Down
Loading