【PHP】PDOを使ってPaginatorを作ってみる

PHP

フレームワークや外部ライブラリ禁止のプロジェクトで、Laravelのページネーション paginate(5) のような機能を作ってみるという案件です。

どこまでやるのか

1.PDOStatementをラッピングし、paginate()メソッド追加。

2.paginate() でLengthAwarePaginatorを返す。

3.LengthAwarePaginator::links() でページネーション出力。

前提条件

  • PHP8.2以降(筆者の環境はPHP8.4.8)
  • PDOが使える状態(デフォルトで有効)
  • PDOでDB接続可能な状態(筆者の環境はMySQL9.3.0)
  • WEBサーバーでPHPが利用可能な状態(筆者の環境はnginx1.26.x)

※次のリポジトリをクローンしてdockerコンテナを構築すると上記条件をクリアできます。

GitHub - macocci7/docker-la12-al2023-minio: Skelton of docker environment with Laravel12, Amazon Linux 2023 and MinIO
Skelton of docker environment with Laravel12, Amazon Linux 2023 and MinIO - macocci7/docker-la12-al2023-minio

今回作成するファイルセットは少し量が多いので、ファイルセットをGithubリポジトリにアップしておきました。

GitHub - macocci7/pure-php-paginator: If you are prohibited from using frameworks like Laravel, this Paginator with PDOStatement wrapper might come to your rescue.
If you are prohibited from using frameworks like Laravel, this Paginator with PDOStatement wrapper might come to your re...

リポジトリをクローンして、色々といじってみてください。

PDOStatementラッパー作成

PDOStatementクラスを継承したラッパークラスを作成ます。

この中に「paginate()」メソッドを追加します。

LengthAwarePaginatorを生成して返す際に、検索結果のレコードを表示件数分に絞って渡します。

▼「libs/DBStatement.php」

<?php

namespace Libs;

use Libs\LengthAwarePaginator;
use PDOStatement;

/**
 * PDOStatement のラッパー
 * 
 * pagination対応が目的
 */
class DBStatement extends PDOStatement
{
	public function paginate(int $perPage = 10, int $currentPage = 0): LengthAwarePaginator|null
	{
		$page = (int) ($_GET["page"] ?? null);
		$lastPage = (int) ceil($this->rowCount() / $perPage);
		$page = $page > $lastPage ? $lastPage : $page;
		if ($currentPage === 0) {
			$currentPage = $page > 0 ? $page : 1;
		}
		$records = [];
		$offset = $perPage * ($currentPage - 1);
		$total = $this->rowCount();
		foreach (array_slice($this->fetchAll(), $offset, $perPage) as $row) {
			$records[] = $row;
		}
		return new LengthAwarePaginator($records, $total, $perPage, $currentPage);
	}
}

PDOStatementラッパーの適用設定

上記で作成したクラスを有効にするためには、「PDO::setAttribute()」メソッドを使って指定します。

▼「libs/DB.php」

<?php

namespace Libs;

use Libs\DBStatement;
use Libs\Log;
use PDO;

class DB {

    public bool $status = false;
    public PDO|null $dbh = null;

    public function __construct() {
        $this->connectToDatabase();
    }

    public function __destruct() {
        $this->dbh = null;
        $this->status = false;
    }

    public function connectToDatabase(): void {
        try {
            $dsn = sprintf(
                "%s:host=%s;dbname=%s;port=%d",
                "mysql",    // PDO driver: https://www.php.net/manual/ja/pdo.drivers.php
                "mysql",    // host
                "laravel",  // database
                3306        // port
            );
            $this->dbh = new PDO(
                $dsn,
                "root", // db user
                "pass"  // db password
            );
            $this->dbh->exec("SET NAMES utf8");
            $this->dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
            $this->dbh->setAttribute(PDO::ATTR_STATEMENT_CLASS, [DBStatement::class]);  // ←ここで設定
            $this->status = true;

        } catch (\Throwable $e) {
            Log::exception($e);
            throw $e;
        }
    }

    /**
     * @param	string	$sql
     * @param	array<string, int|float|string>	$params = []
     */
    public function raw(string $sql, array $params = []): DBStatement {
        try {
            $statement = $this->dbh->prepare($sql);
            $statement->execute($params);
            return $statement;
        } catch (\Throwable $e) {
            Log::exception($e);
            throw $e;
        }
    }
}

LengthAwarePaginatorクラス

DBStatementクラスから返されるPaginator本体です。

ArrayObjectのラッパーなので、foreach等のループでそのまま使えます。

▼「libs/LengthAwarePaginator.php」

<?php

namespace Libs;

use Libs\HttpQueryString;
use ArrayObject;

class LengthAwarePaginator extends ArrayObject
{
	public int $onEachSide = 3;
	public int $lastPage = 1;
	public string $queryString = "";
	public string|null $fragment = null;

	public function __construct(
		public array $records,
		public int $total,
		public int $perPage = 10,
		public int $currentPage = 1,
	) {
		$this->setData();
	}

	protected function setData(): void {
		foreach($this->records as $record) {
			$this->append($record);
		}
		unset($this->records);
		$this->lastPage = (int) ceil($this->total / $this->perPage);
		if ($this->currentPage > $this->lastPage) {
			$this->currentPage = $this->lastPage > 0 ? $this->lastPage : 1;
		}
		$this->setQueryString();
	}

	protected function setQueryString(): void
	{
		$queries = (new HttpQueryString)->get() ?? [];
		unset($queries["page"]);
		$queryString = http_build_query($queries);
		$queryString = strlen($queryString) > 0 ? $queryString  . "&" : "";
		$this->queryString = $queryString;
	}

	public function onEachSide(int $onEachSide): self
	{
		$this->onEachSide = $onEachSide;
		return $this;
	}

	public function fragment(string|null $fragment = null): self
	{
		$this->fragment = $fragment;
		return $this;
	}

	public function links(): void
	{
		view("pagination", ["total" => $this->total, "linkItems" => $this->linkItems()])->render();
	}

	public function getPageUrl(int $page): string
	{
		return "?" . $this->queryString . "page=" . $page . (is_null($this->fragment) ? "" : "#" . $this->fragment);
	}

	public function linkItems(): array
	{
		// Previous
		$linkItems = [new LinkItem([
			"url" => $this->currentPage === 1 ? null : $this->getPageUrl($this->currentPage - 1),
			"label" => "&laquo; Previous",
			"active" => false,
		])];
		// Each pages
		for ($i = 1; $i <= $this->lastPage; $i++) {
			if ($i < $this->currentPage - $this->onEachSide - 1 || $i > $this->currentPage + $this->onEachSide + 1) {
				continue;
			}
			if ($i === $this->currentPage - $this->onEachSide - 1 || $i === $this->currentPage + $this->onEachSide + 1) {
				$linkItems[] = new LinkItem(["url" => null, "label" => "...", "active" => false]);
				continue;
			}
			$linkItems[] = new LinkItem([
				"url" => $this->getPageUrl($i), "label" => (string) $i, "active" => $i === $this->currentPage]);
		}
		// Next
		$linkItems[] = new LinkItem([
			"url" => $this->currentPage >= $this->lastPage ? null : $this->getPageUrl($this->currentPage + 1),
			"label" => "Next &raquo;",
			"active" => false,
		]);
		return $linkItems;
	}
}

LinkItemクラス

Paginationビューに渡すリンクアイテムのクラスです。

保持するプロパティは

  • url : リンク先
  • label : リンクテキスト
  • active : 現在のページかどうか

▼「Libs/LinkItem.php」

<?php

namespace Libs;

use Libs\DynamicProperty;

class LinkItem extends DynamicProperty {
}

「Libs\DynamicProperty」クラスは以前の記事で作成したものです。

テンプレートエンジン

指定されたパスのビューファイルを「require」するだけです。

テンプレート変数は、「render()」メソッド内の変数としてセットしておけば、「require」したPHPファイル側でそのまま利用できます。

▼「Libs\View.php」

<?php

namespace Libs;

class View
{
    public static string $templateRoot;

    public function __construct(
        public string $templatePath,
        public array $params = [],
    ) {
    }

    /**
     * @param	string					$_KEY_
     * @param	array<string, mixed>	$_PARAMS_
     */
    public static function make(string $key, array $params): static
    {
        $path = static::getTemplatePath($key);
        if (! is_readable($path)) {
            throw new \Exception("Template [{$path}] is not readable.");
        }

        return new static($path, $params);
    }

    protected static function setTemplateRoot(): void
    {
        static::$templateRoot = realpath(__DIR__ . "/../app/Views");
    }

    protected static function getTemplatePath(string $key): string
    {
        if (! isset(static::$templateRoot)) {
            static::setTemplateRoot();
        }
        $keys = explode('.', $key);
        $file = array_pop($keys);
        return sprintf(
            "%s/%s/%s.view.php",
            static::$templateRoot,
            implode('/', $keys),
            $file
        );
    }

    public function render(): void
    {
        if (! is_readable($this->templatePath)) {
            throw new \Exception("Template [{$this->templatePath}] is not readable.");
        }

        // テンプレート変数との衝突を極力避ける為の変数名: $_K_ / $_V_
        foreach ($this->params as $_K_ => $_V_) {
            ${$_K_} = $_V_;
        }

        require $this->templatePath;
    }
}

Paginationビュー

Paginationビューファイルです。

▼「app/Views/pagination.view.php」

<p><?php echo $total ?> found</p>
<nav>
	<ul class="pagination">
		<?php
			foreach($linkItems as $item) {
				if(is_null($item->url)) {
		?>
			<li class="page-item disabled">
                <span class="page-link"><?php echo $item->label ?></span>
            </li>
		<?php
				} elseif($item->active) {
		?>
		<li class="page-item active">
			<a class="page-link" href="<?php echo $item->url ?>">
                <?php echo $item->label ?> <span class="sr-only">(current)</span>
            </a>
		</li>
		<?php
				} else {
		?>
			<li class="page-item">
                <a class="page-link" href="<?php echo $item->url ?? "" ?>">
                    <?php echo $item->label ?>
                </a>
            </li>
		<?php
				}
			}
		?>
	</ul>
</nav>

使用例

他にもいくつかファイルがありますが、あまり重要ではないので説明は端折ります。

Githubリポジトリのファイルを見てみてください。

GitHub - macocci7/pure-php-paginator: If you are prohibited from using frameworks like Laravel, this Paginator with PDOStatement wrapper might come to your rescue.
If you are prohibited from using frameworks like Laravel, this Paginator with PDOStatement wrapper might come to your re...

使用例は次のような感じです。

<?php

require __DIR__ . "/../libs/loader.php";

use Libs\DB;

// 1ページ5件で取得
$users = (new DB)->raw("SELECT * FROM users")->paginate(5);

// ループ処理
foreach ($users as $user) {
    echo $user["name"];
}

// Paginationリンク出力
$user->links();

DBレコードの準備として、Laravelでダミーデータを作成してみます。

データベース「laravel」の「users」テーブルに100件のレコードを作成します。

php artisan tinker
\App\Models\User::factory()->count(100)->create();

上記リポジトリ内の使用例を実行してみます。

▼「public/index.php」

<!doctype html>
<html>
    <head>
        <title>Pure PHP Paginator with PDOStatement Wrapper</title>
        <meta charset="utf-8">
    </head>
    <body>
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
        <div class="card">
            <div class="card-body">
<?php

require __DIR__ . "/../libs/loader.php";

use Libs\DB;

$sql = "SELECT * FROM users";
$perPage = 3;
$onEachSide = 1;
$currentPage = $_GET["page"] ?? 1;

$users = (new DB)->raw($sql)->paginate($perPage)->onEachSide($onEachSide);
$lastPage = $users->lastPage;
?>
    <p>
<?php
echo "Page {$currentPage}/{$lastPage}:" . PHP_EOL;
?>
    </p>
    <ul class="list-group">
    <?php
    foreach ($users as $user) {
    ?>
        <li class="list-group-item">
            <?php echo sprintf("%3d: %s &lt;%s&gt;", $user["id"], $user["name"], $user["email"]) ?>
        </li>
    <?php
    }
    ?>
    </ul>
    <?php
    $users->links();
    ?>
            </div>
        </div>
    </body>
</html>

CLI上で実行してみます。

php public/index.php

▼実行結果:

<!doctype html>
<html>
    <head>
        <title>Pure PHP Paginator with PDOStatement Wrapper</title>
        <meta charset="utf-8">
    </head>
    <body>
        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
        <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
        <div class="card">
            <div class="card-body">
    <p>
Page 1/34:
    </p>
    <ul class="list-group">
            <li class="list-group-item">
              1: Mae Funk &lt;jaqueline72@example.com&gt;        </li>
            <li class="list-group-item">
              2: Miss Kathlyn Robel &lt;roel.carroll@example.com&gt;        </li>
            <li class="list-group-item">
              3: Prof. Orland Little &lt;sallie00@example.com&gt;        </li>
        </ul>
    <p>100 found</p>
<nav>
        <ul class="pagination">
                                        <li class="page-item disabled">
                <span class="page-link">&laquo; Previous</span>
            </li>
                                <li class="page-item active">
                        <a class="page-link" href="?page=1">
                1            </a>
                </li>
                                        <li class="page-item">
                <a class="page-link" href="?page=2">
                    2                </a>
            </li>
                                        <li class="page-item disabled">
                <span class="page-link">...</span>
            </li>
                                        <li class="page-item">
                <a class="page-link" href="?page=2">
                    Next &raquo;                </a>
            </li>
                        </ul>
</nav>
            </div>
        </div>
    </body>
</html>

WEBブラウザで表示すると次のようになります。

ここで用意したクラスはこの記事用に簡素化しているので、

自分用にカスタマイズしてみてください。

コメント

タイトルとURLをコピーしました