Skip to content

[6.x] Resolve Entry::descendants() N+1 query#14773

Merged
jasonvarga merged 4 commits into
statamic:6.xfrom
SteveEdson:fix/entry-descendants-n1-query-count
Jun 3, 2026
Merged

[6.x] Resolve Entry::descendants() N+1 query#14773
jasonvarga merged 4 commits into
statamic:6.xfrom
SteveEdson:fix/entry-descendants-n1-query-count

Conversation

@SteveEdson
Copy link
Copy Markdown
Contributor

Resolves #14767.

Entry::descendants() resolved the localization tree by recursing into every node and calling directDescendants() (one query) per localization, which is O(number of localizations) in queries. Because Routing\ResolveRedirect calls $entry->in($site) (which goes through descendants()) for every entry-link field, a page on a heavily localized multi-site can fire hundreds of queries just resolving link targets.

This walks the tree breadth-first instead, fetching each level in a single batched whereIn('origin', ...). directDescendants() is left unchanged, so its Blink cache and the existing invalidation continue to work, and the returned set is identical.

Measured on a ~70-locale site (v6.20.0)

A root entry with 41 localizations:

  • Before: descendants() issued 44 queries
  • After: 4 queries
  • Identical result (same locales, same entry IDs)

Tests

Added to tests/Data/Entries/EntryTest.php:

  • it_resolves_descendants_with_one_query_per_level_rather_than_one_per_localization asserts a flat tree takes a fixed number of queries no matter how many localizations exist. It fails on the previous one-query-per-node implementation.
  • it_includes_descendants_nested_via_an_origin_chain asserts a grandchild reachable only through a nested origin chain is still included, so the flattened result is unchanged.

descendants() recursed into every localization, calling directDescendants()
(one query) per node, which is O(number of localizations). It now walks the
tree breadth-first, fetching each level in a single batched whereIn.
Copilot AI review requested due to automatic review settings June 3, 2026 14:44
@SteveEdson SteveEdson changed the title Resolve Entry::descendants() N+1 query [6.x] Resolve Entry::descendants() N+1 query Jun 3, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR optimizes entry descendant resolution to avoid N+1 queries across localizations, and adds tests to enforce the new query behavior and correctness across origin chains.

Changes:

  • Reworked Entry::descendants() to traverse localization descendants breadth-first with one query per depth level.
  • Added tests to assert the query count is level-based (not per-localization) and to validate nested origin-chain descendants are included.
  • Added a test helper for faking descendant queries.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/Entries/Entry.php Switches descendants traversal to breadth-first batched queries to reduce query count.
tests/Data/Entries/EntryTest.php Adds regression tests for query batching and origin-chain descendant inclusion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Entries/Entry.php Outdated
Comment thread src/Entries/Entry.php Outdated
Comment on lines +1634 to +1637
$query = Mockery::mock(QueryBuilder::class);
$query->shouldReceive('where')->andReturnSelf();
$query->shouldReceive('whereIn')->andReturnSelf();
$query->shouldReceive('get')->andReturn($results);
SteveEdson and others added 3 commits June 3, 2026 15:51
Track visited IDs so cyclic or duplicate origin data cannot loop forever,
and assert the batched whereIn receives the expected origin IDs per level.
The batched levels use whereIn, not where, so stubbing where('origin')
on those mocks was misleading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jasonvarga jasonvarga merged commit 3920bd5 into statamic:6.x Jun 3, 2026
18 checks passed
@jasonvarga
Copy link
Copy Markdown
Member

Very nice, thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Entry::descendants() is O(localizations) queries; multisite link/redirect resolution scales with site count

3 participants