Skip to content

Commit 3920bd5

Browse files
SteveEdsonclaudejasonvarga
authored
[6.x] Resolve Entry::descendants() N+1 query (#14773)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent 3c2735d commit 3920bd5

2 files changed

Lines changed: 88 additions & 2 deletions

File tree

src/Entries/Entry.php

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -869,8 +869,26 @@ public function descendants()
869869
{
870870
$localizations = $this->directDescendants();
871871

872-
foreach ($localizations as $loc) {
873-
$localizations = $localizations->merge($loc->descendants());
872+
// Breadth-first: fetch each level in one batched query instead of one query per node.
873+
$origins = $localizations->map->id()->values()->all();
874+
$seen = array_merge($origins, [$this->id()]);
875+
876+
while (! empty($origins)) {
877+
$children = Facades\Entry::query()
878+
->where('collection', $this->collectionHandle())
879+
->whereIn('origin', $origins)
880+
->get()
881+
// Guard against cyclic or duplicate origin data, which would
882+
// otherwise loop forever as the same entries reappear.
883+
->reject(fn ($entry) => in_array($entry->id(), $seen, true));
884+
885+
if ($children->isEmpty()) {
886+
break;
887+
}
888+
889+
$localizations = $localizations->merge($children->keyBy->locale());
890+
$origins = $children->map->id()->values()->all();
891+
$seen = array_merge($seen, $origins);
874892
}
875893

876894
return $localizations;

tests/Data/Entries/EntryTest.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1577,6 +1577,74 @@ public function it_propagates_entry_if_configured()
15771577
});
15781578
}
15791579

1580+
#[Test]
1581+
public function it_resolves_descendants_with_one_query_per_level_rather_than_one_per_localization()
1582+
{
1583+
$collection = (new Collection)->handle('pages')->save();
1584+
1585+
$entry = (new Entry)->id('a')->locale('en')->collection($collection);
1586+
1587+
// Three direct localizations, all leaves (no further descendants).
1588+
$fr = (new Entry)->id('b')->locale('fr')->origin('a')->collection($collection);
1589+
$de = (new Entry)->id('c')->locale('de')->origin('a')->collection($collection);
1590+
$es = (new Entry)->id('d')->locale('es')->origin('a')->collection($collection);
1591+
1592+
// A flat tree is one level deep, so it should take exactly two queries
1593+
// (the direct descendants, then a single batched lookup for the next
1594+
// level which comes back empty) regardless of how many localizations
1595+
// exist. The previous recursive implementation issued one query per
1596+
// node, i.e. O(number of localizations).
1597+
Facades\Entry::shouldReceive('query')->times(2)->andReturn(
1598+
$this->fakeDescendantsQuery(collect([$fr, $de, $es])), // direct descendants of 'a'
1599+
$this->fakeDescendantsQuery(collect(), ['b', 'c', 'd']), // batched lookup for the next level
1600+
);
1601+
1602+
$descendants = $entry->descendants();
1603+
1604+
$this->assertEquals(['de', 'es', 'fr'], $descendants->keys()->sort()->values()->all());
1605+
}
1606+
1607+
#[Test]
1608+
public function it_includes_descendants_nested_via_an_origin_chain()
1609+
{
1610+
$collection = (new Collection)->handle('pages')->save();
1611+
1612+
$entry = (new Entry)->id('a')->locale('en')->collection($collection);
1613+
1614+
// de's origin is fr, whose origin is en: a grandchild only reachable by
1615+
// walking past the first level. The flattened result must include it.
1616+
$fr = (new Entry)->id('b')->locale('fr')->origin('a')->collection($collection);
1617+
$de = (new Entry)->id('c')->locale('de')->origin('b')->collection($collection);
1618+
1619+
Facades\Entry::shouldReceive('query')->times(3)->andReturn(
1620+
$this->fakeDescendantsQuery(collect([$fr])), // direct descendants of en
1621+
$this->fakeDescendantsQuery(collect([$de]), ['b']), // batched descendants of fr
1622+
$this->fakeDescendantsQuery(collect(), ['c']), // batched descendants of de
1623+
);
1624+
1625+
$descendants = $entry->descendants();
1626+
1627+
$this->assertEquals(['de', 'fr'], $descendants->keys()->sort()->values()->all());
1628+
$this->assertSame($fr, $descendants->get('fr'));
1629+
$this->assertSame($de, $descendants->get('de'));
1630+
}
1631+
1632+
private function fakeDescendantsQuery($results, ?array $whereInOrigins = null): QueryBuilder
1633+
{
1634+
$query = Mockery::mock(QueryBuilder::class);
1635+
$query->shouldReceive('where')->with('collection', 'pages')->andReturnSelf();
1636+
1637+
if ($whereInOrigins === null) {
1638+
// directDescendants() uses where('origin', string); batched levels use whereIn.
1639+
$query->shouldReceive('where')->with('origin', Mockery::type('string'))->andReturnSelf();
1640+
}
1641+
1642+
$query->shouldReceive('whereIn')->with('origin', $whereInOrigins ?? Mockery::type('array'))->andReturnSelf();
1643+
$query->shouldReceive('get')->andReturn($results);
1644+
1645+
return $query;
1646+
}
1647+
15801648
#[Test]
15811649
public function it_doesnt_fire_events_when_propagating_entry_and_saved_quietly()
15821650
{

0 commit comments

Comments
 (0)