Skip to content

Commit 7e04d9c

Browse files
authored
Merge pull request #62 from webrium/fix/paginate-ignores-with-eager-load
Fix/paginate ignores with eager load
2 parents 092d018 + 824f937 commit 7e04d9c

2 files changed

Lines changed: 100 additions & 1 deletion

File tree

src/Eloquent/EagerBuilder.php

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,31 @@ public function first(): ?Model
7171
return $loaded->first();
7272
}
7373

74+
/**
75+
* Paginate the results and eager-load all specified relations on
76+
* the current page.
77+
*
78+
* Without this override, paginate() would fall through __call() to
79+
* Query\Builder::paginate(), which calls the raw builder's get()
80+
* internally and never runs eager loading — silently dropping any
81+
* with() that was chained before it, and reintroducing N+1 the
82+
* moment the relation is accessed on the paginated page.
83+
*
84+
* @param int $perPage
85+
* @param int $page
86+
* @return object{total:int,per_page:int,current_page:int,last_page:int,from:int,to:int,data:Collection}
87+
*/
88+
public function paginate(int $perPage = 15, int $page = 1): object
89+
{
90+
$page = $this->builder->paginate($perPage, $page);
91+
92+
$page->data = $page->data->isEmpty()
93+
? $page->data
94+
: $this->modelClass::eagerLoad($page->data, $this->withs);
95+
96+
return $page;
97+
}
98+
7499
/**
75100
* Add more relations to eager-load.
76101
*
@@ -120,4 +145,4 @@ public function __call(string $method, array $args): mixed
120145

121146
return $result;
122147
}
123-
}
148+
}

tests/Integration/RelationsTest.php

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,80 @@ public function test_eager_loading_belongs_to_avoids_n_plus_1(): void
411411

412412
$this->assertSame(2, count(DB::getQueryLog()));
413413
}
414+
415+
// -----------------------------------------------------------------------
416+
// paginate() honours with()
417+
//
418+
// EagerBuilder previously only implemented get() and first() as
419+
// terminal methods that trigger Model::eagerLoad(). paginate() was
420+
// not declared on EagerBuilder, so it fell through __call() straight
421+
// to the underlying Query\Builder::paginate(), which calls its own
422+
// get() internally — never touching eager loading at all.
423+
//
424+
// The practical effect was: with() silently did nothing when
425+
// paginate() was used. Accessing the relation afterwards fell back
426+
// to lazy loading per row, reproducing the exact N+1 problem with()
427+
// exists to prevent — with no error or warning to signal it.
428+
//
429+
// EagerBuilder::paginate() now overrides the forwarded call, runs the
430+
// page query, then eager-loads relations on $page->data before
431+
// returning. These tests verify that fix.
432+
// -----------------------------------------------------------------------
433+
434+
public function test_paginate_after_with_eager_loads_relation(): void
435+
{
436+
$this->seedManyUsersWithPosts(userCount: 5);
437+
438+
$result = RelUser::with('posts')->paginate(5, 1);
439+
$first = $result->data->first();
440+
441+
$this->assertTrue(
442+
$first->relationLoaded('posts'),
443+
'paginate() must honour with() and eager-load relations on the current page.',
444+
);
445+
$this->assertInstanceOf(Collection::class, $first->posts);
446+
}
447+
448+
public function test_paginate_after_select_with_eager_loads_relation(): void
449+
{
450+
// Same fix verified via the select()->with()->paginate() chain.
451+
$this->seedManyUsersWithPosts(userCount: 5);
452+
453+
$result = RelUser::select('id', 'name')->with('posts')->paginate(5, 1);
454+
$first = $result->data->first();
455+
456+
$this->assertTrue(
457+
$first->relationLoaded('posts'),
458+
'paginate() must honour with() through the select()->with()->paginate() chain too.',
459+
);
460+
}
461+
462+
public function test_paginate_after_with_avoids_n_plus_1_on_access(): void
463+
{
464+
// The real-world payoff: a paginated list view that does
465+
// Model::with('relation')->paginate(...) and then iterates the
466+
// page printing the relation must NOT trigger N+1 — the relation
467+
// should already be loaded from the single whereIn() query.
468+
$userCount = $this->seedManyUsersWithPosts(userCount: 10, postsPerUser: 1);
469+
470+
DB::flushQueryLog();
471+
DB::enableQueryLog();
472+
473+
$result = RelUser::with('posts')->paginate($userCount, 1);
474+
foreach ($result->data as $user) {
475+
$user->posts->count(); // must be a no-op — already eager-loaded
476+
}
477+
478+
$queryCount = count(DB::getQueryLog());
479+
480+
// 1 count() + 1 page select + 1 whereIn() for posts = 3, regardless
481+
// of how many users are on the page.
482+
$this->assertSame(
483+
3,
484+
$queryCount,
485+
'paginate() + with() must use a constant 3 queries, not one extra query per row.',
486+
);
487+
}
414488
}
415489

416490
// -----------------------------------------------------------------------

0 commit comments

Comments
 (0)