PDOとPDOStatementを掩蔽し、実行したSQLのログを出力する方法

| [../](https://qiita.com/taconana/items/3f5424f9abfc755772c2) |
|---|

PHPでDBにアクセスするためのベースクラスを作成してみた。PHPでは、ブラウザで画面が遷移するごと(redirectするごと)に新たなプロセスが生成されるので、DBへの接続情報をプーリングしたり、セッション($_SESSION)に保持したり、メモリ上にキャッシュしても意味がないことが分かってきた。新たなプロセスで必要となったときに、DBに接続し、PDOを生成する。そのプロセスが有効である間は、PDOを使い回して複数のSQLを発行できる。

SQLの発行は、プレペアステートメントを利用したいが、SQLのログが綺麗に出力できない。調べてみてもいい方法が見つからなかったので、独自に書いてみた。データ型によっては動作は未確認だが、int,bool,float,string,dateなどの基本型については問題ないので、紹介しておきたい。

## SQLをログ出力できるプレペアステートメントの実現

例えば、DBのユーザー情報にアクセスするためのクラス(DAO)をUserDataAccessorとして、ユーザー取得のメソッドgetUser()とパスワード変更のメソッドupdatePassword()を以下のように記述したいと想定する。プレペアステートメントを利用していることが分かるだろう。しかし、PDOは掩蔽され、更新系ではPDOStatementも掩蔽されている。参照系は、fetchAll()を使う場合にはPDOStatementを掩蔽できるが、多くの場合にはfetch()を使いたいのでPDOStatementは見えるようにしている。query()、execute()のメソッドでは、「?」を含んだSQLにパラメータを差し込む部分も掩蔽してみた。
```php:UserDataAccessor.php
<?php
namespace users\integration;

require_once(__DIR__."/../../common/integration/PersistenceBase.php");
require_once(__DIR__."/../domain/User.php");
use common\integration\PersistenceBase;
use users\domain\User;

class UserDataAccessor extends PersistenceBase {

  public function getUser(string $uid) {
    $user = null;
    $sql = "select * from user where user_id=?";
    $stmt = $this->query($sql, $uid);
    if ($stmt != null){
      if ($row = $stmt->fetch()) {
        $user = $this->createUser($row);
      }
      $this->logQuery($stmt);
    }
    return $user;
  }
  
  public function updatePassword(User &$user, string $pwd): int {
    $sql = "update user set password=? where user_id=?";
    $p1 = $pwd;
    $p2 = $user->getUid();
    $cnt = $this->execute($sql, $p1, $p2);
    if ($cnt > 0) {
      $user->setPassword($pwd);
    }
    return $cnt;
  }
}
```
これらの2つのメソッドを以下のように実行してみる。CommonProps::$propsは、DBの接続先の情報を保持したarrayである。後述する。
```php
$accessor = new UserDataAccessor(CommonProps::$props);
$user = $accessor->getUser('U001');
$accessor->updatePassword($user, 'q5Nj6atX');
```
実行されたSQLと処理した件数を、以下の感じでログ出力したい。表示されるSQLは「?」にパラメータが差し込まれていることが目標である。ログは、参照系ではlogQuery()で、更新系ではexecute()内で出力する仕様にしてみた。
```
[16:04:xx 2022] @@@ select * from user where user_id='U001' --> 1
[16:06:xx 2022] @@@ update user set password='q5Nj6atX' where user_id='U001' --> 1
```
ログのSQL部分をコピペしてMySQLコンソールに貼り付けたら、そのまま実行できるレベルにしておきたい。何件を検索したのか、何件を処理したのかをログ出力したい。DBの接続先情報は、CommonProps::propsというarrayにハードコーディングしておく。
```php:CommonProps.php
<?php 
namespace common\integration;

class CommonProps {

  public static $props = [
      'db.dbms' => 'mysql',
      'db.dbname' => 'test',
      'db.host' => 'localhost',
      'db.port' => '3306',
      'db.charset' => 'utf8',
      'db.user' => 'root',
      'db.password' => 'admin',
  ];
  
  public static function __initialize(){
    $a = &self::$props;
    $a['db.dsn'] = $a['db.dbms'] 
    . ":dbname=". $a['db.dbname']
    . ";host=" . $a['db.host']
    . ";port=" . $a['db.port']
    . ";charset=" . $a['db.charset'];
  }
}
CommonProps::__initialize();
```

## PDOやPDOStatementを掩蔽するためのベースクラスの実装

UserDataAccessorが継承するPersistenceBaseは、以下のように実装してみた。PDOやPDOStatementを掩蔽し、発行したSQLと処理した件数をログ出力できるようにしている。基本的なデータ型は、内部でbindValue()できるようにしている。（※提示のコードでは、query()中に新たにSQLを発行する場合に対応できないので注意のこと。実装はできているのだが、若干複雑になるので提示を割愛している。)
```php:PersistenceBase.php
<?php
namespace common\integration;
use PDO;
use PDOException;
use PDOStatement;

class PersistenceBase {
  
  private $props;
  private $pdo;
  private $isTrace;
  private $lastlog;

  public function __construct(array &$props) {
    $this->props = $props;
    $this->isTrace = true;
  }
  
  private function pdo(): PDO {
    if (empty($this->pdo)){
      $dsn = $this->props['db.dsn'];
      $user = $this->props['db.user'];
      $password = $this->props['db.password'];
      $options = [
          PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
          PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
          // PDO::ATTR_EMULATE_PREPARES, false,
          PDO::ATTR_PERSISTENT => true
      ];
      try {
        $this->pdo = new PDO($dsn, $user, $password, $options);
        error_log("@@@ connected " . $dsn);
      } catch (PDOException $e) {
        error_log('@@@ connect failed: ' . $e->getMessage());
      }
    }
    return $this->pdo;
  }
  
  public function execute($sql, ...$param): int {
    $log = &$sql;
    $cnt = 0;
    try {
      $stmt = $this->pdo()->prepare($sql);
      $this->appendParam($stmt, $log, $param);
      $stmt->execute();
      $cnt = $stmt->rowCount();
      $this->logCount($log, $cnt);
    } catch (PDOException $e) {
      $this->logError($log, $e);
    }
    return $cnt;
  }
  
  public function fetchAll($sql, ...$param) {
    $log = &$sql;
    $rows = null;
    try {
      $stmt = $this->pdo()->prepare($sql);
      $this->appendParam($stmt, $log, $param);
      $stmt->execute();
      $rows = $stmt->fetchAll();
      $this->logCount($log, $stmt->rowCount());
    } catch (PDOException $e) {
      $this->logError($log, $e);
    }
    return $rows;
  }
  
  public function query($sql, ...$param) {
    $log = &$sql;
    $stmt = null;
    try {
      $stmt = $this->pdo()->prepare($sql);
      $this->appendParam($stmt, $log, $param);
      $stmt->execute();
      $this->lastlog = $log;
    } catch (PDOException $e) {
      $this->logError($log, $e);
      $stmt = null;
    }
    return $stmt;
  }
  
  public function logQuery(PDOStatement &$stmt): void {
    if ($this->isTrace){
      $this->logCount($this->lastlog, $stmt->rowCount());
    }
    $stmt = null;
  }
  
  private function appendParam(&$stmt, &$log, $param): void {
    $n = 1;
    foreach ($param as $p){
      if (is_null($p)){
        $stmt->bindValue($n, null, PDO::PARAM_NULL);
        $this->appendValue($log, null);
      } else if (is_bool($p)){
        $stmt->bindValue($n, $p, PDO::PARAM_BOOL);
        $this->appendValue($log, $p);
      } else if (is_int($p)){
        $stmt->bindValue($n, $p, PDO::PARAM_INT);
        $this->appendValue($log, $p);
      } else {
        $stmt->bindValue($n, $p, PDO::PARAM_STR);
        $this->appendValue($log, $p, true);
      }
      $n++;
    }
  }
  
  private function appendValue(string &$log, $p, bool $quote = false): void {
    if ($this->isTrace){
      $n = strpos($log, '?');
      if ($quote){
        $p = '\'' . $p . '\'';
      }
      $log = substr($log, 0, $n) . $p . substr($log, $n + 1);
    }
  }
  
  private function logCount(string &$sql, int $cnt): void {
    if ($this->isTrace){
      error_log('@@@ ' . $sql . ' --> ' . $cnt);
    }
  }
  
  private function logError(string &$sql, PDOException &$e): void {
    if ($this->isTrace){
      error_log('@@@ ' . $sql . ' --> ' . $e->getMessage());
    } else {
      error_log('@@@ ' . $e->getMessage());
    }
  }
}
```
| [../](https://qiita.com/taconana/items/3f5424f9abfc755772c2) |
|---|