diff --git a/docs/ja/MediaQuery.md b/docs/ja/MediaQuery.md index f0a10ed..2faf11e 100644 --- a/docs/ja/MediaQuery.md +++ b/docs/ja/MediaQuery.md @@ -107,11 +107,12 @@ interface UserListInterface } ``` -実行結果の`Pages`は、配列アクセスでページを取得したり`count()`で全体の件数を取得することができます。 +実行結果の`Pages`は、配列アクセスでページを、`count()`で全体の件数を、`getNbPages()`で総ページ数を取得できます。 ```php $userList = $injector->getInstance(UserListInterface::class); $pages = $userList(); $page = $pages[1]; $total = count($pages); +$nbPages = $pages->getNbPages(); ``` diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 0340306..2a05502 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -84,7 +84,7 @@ Every row of this table is a valid return type on a `#[DbQuery]` method. | `AffectedRows` | UPDATE/DELETE | Exposes `->count` (int) and `->isAffected(): bool`. | | `InsertedRow` | INSERT | Exposes `->values` (resolved params) and `->id` (?string lastInsertId). | | custom `X implements PostQueryInterface` | SELECT or DML | Polymorphic dispatch — framework calls `X::fromContext($context)`. SELECT pre-hydrates rows on `$context->rows`; DML leaves `$context->rows = []`. | -| `Pages` (with `#[Pager]`) | SELECT | Lazy paginator. Index access executes `LIMIT`/`OFFSET`; `count()` does `COUNT`. | +| `Pages` (with `#[Pager]`) | SELECT | Lazy paginator. Index access executes `LIMIT`/`OFFSET`; `count()` does `COUNT`, and `getNbPages()` divides it by `perPage`. | How dispatch is decided: @@ -400,10 +400,11 @@ interface ProductRepository $pages = $repo->list(); count($pages); // → COUNT query +$pages->getNbPages(); // → number of pages, from the same COUNT (memoized) $page = $pages[1]; // → SELECT with LIMIT/OFFSET, rows hydrated to Product $page->data; // items $page->current; // int -$page->total; // int (total pages) +$page->total; // int (total item count) $page->hasNext; // bool $page->hasPrevious; // bool (string) $page; // rendered pager HTML diff --git a/docs/reference.md b/docs/reference.md index df8e8fa..667a8d6 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -610,8 +610,9 @@ interface ProductRepository } $pages = $productRepo->getProducts(); -$count = count($pages); // COUNT query を実行 -$page = $pages[1]; // LIMIT/OFFSET 付き SELECT を実行 +$count = count($pages); // COUNT query を実行 +$nbPages = $pages->getNbPages(); // 総ページ数。件数を取ってceilする必要はない +$page = $pages[1]; // LIMIT/OFFSET 付き SELECT を実行 // Page object properties: // $page->data // このページの item diff --git a/docs/tutorial/README.ja.md b/docs/tutorial/README.ja.md index 3e7f218..cefde29 100644 --- a/docs/tutorial/README.ja.md +++ b/docs/tutorial/README.ja.md @@ -1333,7 +1333,7 @@ first stats row=MyBlog\ArticleStats commentCount=2 excerpt='Updated body.' ### 解説 -- `count($pages)` は **総アイテム数** (= COUNT クエリの結果)。総ページ数ではないので注意。 +- `count($pages)` は **総アイテム数** (= COUNT クエリの結果)。総ページ数は `$pages->getNbPages()` で取る (同じ COUNT を使い回す)。 - `$pages[1]` でページ1にアクセス → SELECT に LIMIT/OFFSET が付いて実行される (lazy)。 - `$page->data` は Article のリスト (`@return Pages
` のおかげで hydration が効く)。 - `#[DbQuery(factory: ArticleStatsFactory::class)]` と `#[Pager]` を併用した場合、1.1 以降は `$page->data` の各行も `ArticleStatsFactory` で作られる。 diff --git a/docs/tutorial/README.md b/docs/tutorial/README.md index c256d29..6d85fbd 100644 --- a/docs/tutorial/README.md +++ b/docs/tutorial/README.md @@ -1331,7 +1331,7 @@ first stats row=MyBlog\ArticleStats commentCount=2 excerpt='Updated body.' ### Explanation -- `count($pages)` returns the **total item count** from the COUNT query, not the number of pages. +- `count($pages)` returns the **total item count** from the COUNT query, not the number of pages. For the page count call `$pages->getNbPages()`, which reuses the same COUNT. - `$pages[1]` accesses page 1 and lazily executes SELECT with LIMIT/OFFSET. - `$page->data` contains a list of `Article` objects because of `@return Pages
`. - With `#[DbQuery(factory: ArticleStatsFactory::class)]` and `#[Pager]` together, Ray.MediaQuery 1.1+ builds each row in `$page->data` through `ArticleStatsFactory`. diff --git a/src/Exception/InvalidPerPageException.php b/src/Exception/InvalidPerPageException.php new file mode 100644 index 0000000..c104970 --- /dev/null +++ b/src/Exception/InvalidPerPageException.php @@ -0,0 +1,12 @@ +pages->count(); } + + #[Override] + public function getNbPages(): int + { + return $this->pages->getNbPages(); + } } diff --git a/src/Pages.php b/src/Pages.php index 4f820e4..f18cca7 100644 --- a/src/Pages.php +++ b/src/Pages.php @@ -9,9 +9,12 @@ use Ray\AuraSqlModule\Pagerfanta\AuraSqlPagerInterface; use Ray\AuraSqlModule\Pagerfanta\ExtendedPdoAdapter; use Ray\AuraSqlModule\Pagerfanta\Page; +use Ray\MediaQuery\Exception\InvalidPerPageException; use Ray\MediaQuery\Exception\LogicException; +use function ceil; use function is_array; +use function max; /** @template T of class-string|mixed */ final class Pages implements PagesInterface @@ -19,6 +22,13 @@ final class Pages implements PagesInterface /** @var (callable(array): mixed)|null */ private $rowMapper; + /** + * Memoized result count, shared by count() and getNbPages() as in Pagerfanta + * + * @var int<0, max>|null + */ + private int|null $nbResults = null; + /** * @param array $params * @param (callable(array): mixed)|null $rowMapper @@ -28,8 +38,13 @@ public function __construct( private ExtendedPdoInterface $pdo, private string $sql, private array $params, + private int $perPage, callable|null $rowMapper = null, ) { + if ($this->perPage < 1) { + throw new InvalidPerPageException((string) $this->perPage); + } + $this->rowMapper = $rowMapper; } @@ -93,6 +108,12 @@ public function offsetUnset(mixed $offset): void #[Override] public function count(): int { - return (new ExtendedPdoAdapter($this->pdo, $this->sql, $this->params))->getNbResults(); + return $this->nbResults ??= (new ExtendedPdoAdapter($this->pdo, $this->sql, $this->params))->getNbResults(); + } + + #[Override] + public function getNbPages(): int + { + return max(1, (int) ceil($this->count() / $this->perPage)); } } diff --git a/src/PagesInterface.php b/src/PagesInterface.php index 3716bc4..762f5b2 100644 --- a/src/PagesInterface.php +++ b/src/PagesInterface.php @@ -10,4 +10,8 @@ /** @extends ArrayAccess */ interface PagesInterface extends ArrayAccess, Countable { + /** + * Returns the total number of pages (ceil of result count / perPage, minimum 1 as in Pagerfanta). + */ + public function getNbPages(): int; } diff --git a/src/SqlQuery.php b/src/SqlQuery.php index 0bb8f39..5c42ea3 100644 --- a/src/SqlQuery.php +++ b/src/SqlQuery.php @@ -236,7 +236,7 @@ public function getPages(string $sqlId, array $values, int $perPage, string $que $pager = $this->pagerFactory->newInstance($this->pdo, $this->getSql($sqlId), $values, $perPage, $queryTemplate, $entity); /** @var array $values */ - return new Pages($pager, $this->pdo, $this->getSql($sqlId), $values); + return new Pages($pager, $this->pdo, $this->getSql($sqlId), $values, $perPage); } private function getSql(string $sqlId): string diff --git a/tests/Fake/FakeEmptyPages.php b/tests/Fake/FakeEmptyPages.php index c814381..b8eca3f 100644 --- a/tests/Fake/FakeEmptyPages.php +++ b/tests/Fake/FakeEmptyPages.php @@ -40,4 +40,10 @@ public function count(): int { return $this->iter->count(); } + + public function getNbPages(): int + { + // Empty result set is still one (empty) page, as in Pagerfanta + return 1; + } } diff --git a/tests/Fake/FakePages.php b/tests/Fake/FakePages.php index 6c3bdda..edffb89 100644 --- a/tests/Fake/FakePages.php +++ b/tests/Fake/FakePages.php @@ -30,4 +30,10 @@ public function count(): int { // TODO: Implement count() method. } + + public function getNbPages(): int + { + // TODO: Implement getNbPages() method. + return 0; + } } diff --git a/tests/Fake/MappedPagesFakePages.php b/tests/Fake/MappedPagesFakePages.php index ea1d5a4..74b2833 100644 --- a/tests/Fake/MappedPagesFakePages.php +++ b/tests/Fake/MappedPagesFakePages.php @@ -46,4 +46,9 @@ public function count(): int { return 1; } + + public function getNbPages(): int + { + return 1; + } } diff --git a/tests/MappedPagesTest.php b/tests/MappedPagesTest.php index 66122a5..a155a9e 100644 --- a/tests/MappedPagesTest.php +++ b/tests/MappedPagesTest.php @@ -62,6 +62,17 @@ public function testDelegatesMutationAndCount(): void $this->assertSame(1, $pages->count()); } + public function testDelegatesGetNbPages(): void + { + $delegate = $this->pages($this->page([])); + $pages = new MappedPages( + $delegate, + static fn (array $row): array => $row, + ); + + $this->assertSame(1, $pages->getNbPages()); + } + private function page(mixed $data): Page { $reflection = new ReflectionClass(Page::class); diff --git a/tests/PagesTest.php b/tests/PagesTest.php index 51fa1c1..4e26094 100644 --- a/tests/PagesTest.php +++ b/tests/PagesTest.php @@ -7,6 +7,7 @@ use Aura\Sql\ExtendedPdoInterface; use PHPUnit\Framework\TestCase; use Ray\AuraSqlModule\Pagerfanta\AuraSqlPagerInterface; +use Ray\MediaQuery\Exception\InvalidPerPageException; final class PagesTest extends TestCase { @@ -20,9 +21,71 @@ public function testOffsetGetReturnsNullWhenDelegateHasNoPage(): void $this->createStub(ExtendedPdoInterface::class), 'SELECT 1', [], + 10, ); $this->assertFalse(isset($pages[3])); $this->assertNull($pages[3]); } + + public function testConstructorRejectsZeroPerPage(): void + { + $this->expectException(InvalidPerPageException::class); + + new Pages( + $this->createStub(AuraSqlPagerInterface::class), + $this->createStub(ExtendedPdoInterface::class), + 'SELECT 1', + [], + 0, + ); + } + + public function testConstructorRejectsNegativePerPage(): void + { + $this->expectException(InvalidPerPageException::class); + + new Pages( + $this->createStub(AuraSqlPagerInterface::class), + $this->createStub(ExtendedPdoInterface::class), + 'SELECT 1', + [], + -1, + ); + } + + public function testGetNbPagesReturnsOneWhenEmpty(): void + { + $pdo = $this->createStub(ExtendedPdoInterface::class); + $pdo->method('fetchValue')->willReturn('0'); + + $pages = new Pages( + $this->createStub(AuraSqlPagerInterface::class), + $pdo, + 'SELECT * FROM todo', + [], + 10, + ); + + $this->assertSame(0, $pages->count()); + $this->assertSame(1, $pages->getNbPages()); + } + + public function testCountQueryRunsOnlyOnce(): void + { + $pdo = $this->createMock(ExtendedPdoInterface::class); + $pdo->expects($this->once())->method('fetchValue')->willReturn('3'); + + $pages = new Pages( + $this->createStub(AuraSqlPagerInterface::class), + $pdo, + 'SELECT * FROM todo', + [], + 2, + ); + + $this->assertSame(3, $pages->count()); + $this->assertSame(2, $pages->getNbPages()); + $this->assertSame(2, $pages->getNbPages()); + } } diff --git a/tests/SqlQueryTest.php b/tests/SqlQueryTest.php index 9774e76..79d4baa 100644 --- a/tests/SqlQueryTest.php +++ b/tests/SqlQueryTest.php @@ -108,6 +108,38 @@ public function testPagerCount(Pages $pages): void $this->assertSame(2, count($pages)); } + /** @param Pages $pages */ + #[Depends('testPager')] + public function testPagerNbPages(Pages $pages): void + { + $this->assertSame(2, $pages->getNbPages()); + } + + public function testPagerNbPagesWithPerPage2(): void + { + $this->sqlQuery->exec('todo_add', ['id' => '2', 'title' => 'walk']); + $pages = $this->sqlQuery->getPages('todo_list', [], 2); + + $this->assertSame(1, $pages->getNbPages()); + } + + public function testPagerNbPagesRoundsUp(): void + { + $this->sqlQuery->exec('todo_add', ['id' => '2', 'title' => 'walk']); + $this->sqlQuery->exec('todo_add', ['id' => '3', 'title' => 'sleep']); + $pages = $this->sqlQuery->getPages('todo_list', [], 2); + + $this->assertSame(2, $pages->getNbPages()); + } + + public function testPagerNbPagesMinimumOneWhenEmpty(): void + { + $pages = $this->sqlQuery->getPages('todo_item', ['id' => '__none__'], 10); + + $this->assertSame(0, count($pages)); + $this->assertSame(1, $pages->getNbPages()); + } + public function testCount(): void { $this->sqlQuery->exec('todo_add', ['id' => '2', 'title' => 'walk']);