Posted at
PHPDay 20

PDOでオブジェクトをフェッチ&JSONとCSVファイル出力

More than 5 years have passed since last update.

PDOを使ったデータベース操作でオブジェクトを扱う場合、不便なところが結構あります。

それを PDOStatement を拡張することで使いやすくしようという話を基本に、応用例としてJSON出力とCSVファイル出力の方法を紹介します。


オブジェクトで取得するためのフェッチモード

フェッチモードには多彩なものが用意されているのですが、オブジェクトで取得する場合はどれも使いづらいです。

自分はいわゆるモデルクラスは不変オブジェクトにしたいよ派かつ、マジックメソッド大好きなので、その辺りの使いやすさについても注目してみます。


PDO::FETCH_OBJ

列名をプロパティに持つ匿名オブジェクト (stdClass) を返します。

匿名オブジェクトなんて連想配列と同じだし、今となっては意味がなさそう…。


PDO::FETCH_LAZY

PHPマニュアルの記述からは PDO::FETCH_OBJ とどう違うのかよく分からないモードですが、実際の動作では PDORow というオブジェクトに列名と同名のプロパティがセットされて返されます。

PDORow クラスには何もメソッドが用意されていないようですが、一つ PDO::FETCH_OBJ の場合と異なるのは、queryString というプロパティに値としてSQLがセットされる点です。

でも、このプロパティは PDOStatement にもありますので…作成された意図がよく分からないモードです。


PDO::FETCH_CLASS

モード指定時に、クラス名とコンストラクタに渡す引数を指定します。

フェッチ時には、指定されたクラスのオブジェクトを生成し、プロパティの可視性に関わらず列名と同名のプロパティに値がセットされます。

同名のプロパティが存在しない場合のみ、マジックメソッド __set() が呼ばれます。

$statement = $pdo->prepare(<<<'SQL'

SELECT
user_id AS userId
, user_name AS userName
, created_at AS createdAt
FROM
users
WHERE
user_id = :userId
SQL

);
$statement->bindValue(':userId', 1, \PDO::PARAM_INT);
$statement->execute();

$statement->setFetchMode(\PDO::FETCH_CLASS, '\Acme\Domain\Data\User');
$user = $statement->fetch();

プロパティへの値セットの後で、フェッチモード指定時に与えた引数でコンストラクタが呼ばれる という奇妙な動作をするため、コンストラクタでプロパティの初期化(NULLのセット)を行っている場合、モード指定時のコンストラクタ引数に含まれていないプロパティの値が全て NULL で上書きされるという、悲しい結果となります。

PHP 5.2.0 以降であれば、次のオプション PDO::FETCH_PROPS_LATE を併用することで改善できます。


PDO::FETCH_PROPS_LATE

PHP 5.2.0から導入されたオプションですが、

前述の PDO::FETCH_CLASS と合わせて指定することで、コンストラクタが呼ばれた後でプロパティに値がセットされるようになります。

これによって、コンストラクタでプロパティの初期化(NULLのセット)を行っている場合でも、フェッチしたプロパティの値のみが上書きされることになります。

ただし、 同名プロパティが存在する場合に __set() が呼ばれない という点は変わりません。

__set() で値の変換やバリデーション等を行わない、あるいは、そもそもカラムの値をクラスの同名プロパティで保持しないのであれば(get_object_vars() や property_exists() といった関数が意味を成さなくなる等、それはそれで別な面倒も発生するのですが…)、これと PDO::FETCH_CLASS の組み合わせは有力な候補となりそうです。

なお、PDOStatement::fetchObject() メソッドには PDO::FETCH_PROPS_LATE 相当の機能は用意されていません。


PDO::FETCH_INTO

モード指定時に、オブジェクトを指定します。

フェッチ時には、指定した引数でコンストラクタが実行された後、

列名と同名のプロパティに値をセットしようとします。

$statement = $pdo->prepare(<<<'SQL'

SELECT
user_id AS userId
, user_name AS userName
, created_at AS createdAt
FROM
users
WHERE
user_id = :userId
SQL

);
$statement->bindValue(':userId', 1, \PDO::PARAM_INT);
$statement->execute();

$statement->setFetchMode(\PDO::FETCH_INTO, new \Acme\Domein\Data\User());
$user = $statement->fetch();

PDO::FETCH_CLASS とは違ってプロパティの可視性の影響を受け、 同名のプロパティが存在してもアクセスできない場合は、マジックメソッド __set() が呼ばれます。

PDO::FETCH_CLASS よりは分かりやすい動作なのですが、オブジェクト生成後にプロパティの値をセットするため、不変オブジェクトの生成には使えません。


PDO::FETCH_FUNC

モード指定時に、任意のコールバックを指定します。

フェッチ時には、取得した値をコールバックの引数に受けることができます。

$statement = $pdo->prepare(<<<'SQL'

SELECT
user_id
, user_name
, created_at
FROM
users
WHERE
user_id = :userId
SQL

);
$statement->bindValue(':userId', 1, \PDO::PARAM_INT);
$statement->execute();

$users = $statement->fetchAll(\PDO::FETCH_FUNC,
function($user_id, $user_name, $created_at) {
return new \Acme\Domain\Data\User([
'userId' => $user_id,
'userName' => $user_name,
'createdAt' => $created_at,
]);
}
);

標準で用意されている機能の中では最も融通が効きそうなのですが、なぜか fetchAll() の時しか指定できません。


オブジェクトで取得する際に使うメソッドは?

ここまでいくつかフェッチモードの例を紹介しましたが、それを指定する方法も PDO::setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE) によるデフォルト指定や、PDOStatement::setFetchMode() での指定、PDOStatement::fetch()PDOStatement::fetchAll() での指定など、色々あって慣れていないと分かりづらいです。

オブジェクトで取得する場合、オプション引数を1つしか取らない PDO::setAttribute() による指定は使えないことや、PDOStatement::fetch() の第2引数と第3引数はモード毎のオプションではなくカーソルの設定になっているため、このメソッドで直接モードを指定する場合は PDO::FETCH_CLASS や PDO::FETCH_INTO は事実上使えないこと、PDOStatement には Traversable が実装されているため、そのまま foreach() するケースもあることを考えると、全てのモードに対して完全な引数を指定できる PDOStatement::setFetchMode() 一択になると思います。


標準のメソッドとフェッチモードで頑張ってみる

標準で用意されている方法では、不変オブジェクトの場合は PDO::FETCH_CLASS + PDO::FETCH_PROPS_LATE 、そうでない場合は PDO::FETCH_INTO も候補に上げられそうですが、マジックメソッドを使ってオブジェクト利用側の見えないところで一工夫しようとすると、やはり悩ましい問題があります。

ここまでのサンプルよりも複雑なケースを想定してみます。

DB上では users.created_at はタイムスタンプを数値で格納しており、users.birthday は日付が文字列で格納しているとします。

\Acme\Domain\Data\User クラスは不変オブジェクトとして実装しており、生成するオブジェクトのプロパティはコンストラクタの第1引数で連想配列を指定します。

now プロパティは現在日時の DateTimeImmutable オブジェクトで、dateTimeFormat プロパティは出力時に利用する日時フォーマットを指定します。

プロパティの可視性は全て private ですが、マジックメソッド __get() 経由で値が返されます。

birthday createdAt については __get() 経由でメソッドが呼ばれます。

getBirthday() および getCreatedAt() が DateTimeImmutable オブジェクトを返し、getBirthdayAsString() および getCreatedAtAsString() が文字列で返し、getAge() は now の値を元に現在の年齢を数値で返します。

このような要件の場合、PDO::FETCH_CLASS + PDO::FETCH_PROPS_LATE を利用すると以下のようなコードが考えられます。

※ DateTimeImmutable を使っているので PHP 5.5 以降です。

$now = new \DateTimeImmutable('2013-12-20 00:00:00');

$statement = $pdo->prepare(<<<'SQL'
INSERT INTO
users
(
user_name
, birthday
, created_at
) VALUES (
:user_name
, :birthday
, :created_at
)
SQL

);
$statement->bindValue('user_name', 'test1', \PDO::PARAM_STR);
$statement->bindValue('birthday', '1980-12-20', \PDO::PARAM_STR);
$statement->bindValue('created_at', $now->getTimestamp(), \PDO::PARAM_INT);
$statement->execute();

$statement = $pdo->prepare(<<<'SQL'
SELECT
user_id AS userId
, user_name AS userName
, birthday AS birthday
, created_at AS createdAt
FROM
users
WHERE
user_id = :userId
SQL

);
$statement->bindValue(':userId', 1, \PDO::PARAM_INT);
$statement->execute();

$statement->setFetchMode(\PDO::FETCH_CLASS | \PDO::FETCH_PROPS_LATE,
'\Acme\Domain\Data\User',
[[
'now' => $now,
'dateFormat' => 'Y-m-d',
'dateTimeFormat' => 'Y-m-d H:i:s',
]]
);

foreach ($statement as $user) {
var_dump($user->userId); // string(1) "1"
var_dump($user->userName); // string(5) "test1"
var_dump($user->birthday); // class DateTimeImmutable...
var_dump($user->birthdayAsString); // string (10) "1980-12-20"
var_dump($user->age); // int(33)
var_dump($user->createdAt); // class DateTimeImmutable...
var_dump($user->createdAtAsString); // string (19) "2013-12-20 00:00:00"
}

コンストラクタ経由でフェッチ内容をプロパティとして設定する方法がないため、分かりづらい記述になってしまいました。

テーブルの列名とオブジェクトのプロパティ名の不一致をSQLで無理矢理カバーしているのも、似たようなSQLを自動生成するならともかく手で書くとなると、あまり良い方法とは思えません。

また、PDO::FETCH_CLASS の動作について前述した通り、列名と同名のプロパティがある場合に __set() は呼ばれないため、__set() 経由で createdAt の値を DateTimeImmutable としてセットするようなことができません。

そのためにやむを得ずオブジェクト内でも birthday は文字列のまま、createdAt はタイムスタンプのまま保持しているわけですが、どちらもプロパティの値を取得するたびに同じ値のオブジェクトが生成されてしまいます。

表向きの機能は実装できていますが、これでは不変オブジェクトのメリットが活かされているとは言えないでしょう。


PDOStatementの機能を拡張してみる

標準の方法ではやりたいことを実現できそうにないので、PDOStatement の機能を拡張します。

目標はこんなコードが書けること。

$now = new \DateTimeImmutable('2013-12-20 00:00:00');

$statement = $pdo->prepare("SELECT * FROM users WHERE user_id = :userId");
$statement->execute(['userId' => 1]);
$statement->setFetchMode(\PDO::FETCH_ASSOC);
$statement->setFetchCallback(function($cols) use ($now) {
return new \Acme\Domain\Data\User([
'userId' => (int)$cols['user_id'],
'userName' => $cols['user_name'],
'birthday' => new \DateTimeImmutable($cols['birthday']),
'createdAt' => new \DateTimeImmutable(sprintf('@%d', $cols['created_at'])),
'now' => $now,
'dateFormat' => 'Y-m-d',
'dateTimeFormat' => 'Y-m-d H:i:s',
]);
});

foreach ($statement as $user) {
var_dump($user->userId); // int(1)
var_dump($user->userName); // string(5) "test1"
var_dump($user->birthday); // class DateTimeImmutable...
var_dump($user->birthdayAsString); // string (10) "1980-12-20"
var_dump($user->age); // int(33)
var_dump($user->createdAt); // class DateTimeImmutable...
var_dump($user->createdAtAsString); // string (19) "2013-12-20 00:00:00"
}

フェッチモードに関係なくフェッチ後にコールバック処理を行って返した結果を取得したい。

幸い PDO には PDO::prepare() が返すステートメントクラスを指定するオプション PDO::ATTR_STATEMENT_CLASS が用意されており、これで継承したクラスを使うことができるのですが…。

PDOStatement::fetch() をオーバーライドしてみたものの、残念ながら foreach() が返す値に干渉する方法が分かりませんでした。

PDOStatement は Traversable インタフェースを実装していますが、このインタフェースは Iterator とは違ってメソッド定義が存在しない ので、メソッドのオーバーライドでは対処できないようです。


Acme\PDO\PDOStatement

「継承よりコンポジション」という設計指針もありますし、素の PDOStatement が型として利用されるケースはないだろうとの判断から、継承ではなく PDOStatement をプロパティに持つクラスとしました。

<?php

namespace Acme\PDO;

use Acme\PDO\CallbackIterator;

/**
* PDOStatement
*
* @author k.holy74@gmail.com
*/

class PDOStatement implements \IteratorAggregate
{

/**
* @var PDOStatement
*/

private $statement;

/**
* @param callable フェッチ後に実行するコールバック
*/

private $callback;

/**
* コンストラクタ
*
* @param PDOStatement
*/

public function __construct(\PDOStatement $statement)
{
$this->statement = $statement;
$this->callback = null;
}

/**
* __call
*
* @param string
* @param array
*/

public function __call($name, $args)
{
if (method_exists($this->statement, $name)) {
return call_user_func_array(array($this->statement, $name), $args);
}
throw new \BadMethodCallException(
sprintf('Undefined Method "%s" called.', $name)
);
}

/**
* フェッチ後に実行するコールバックをセットします。
*
* @param callable コールバック
*/

public function setFetchCallback(callable $callback)
{
$this->callback = $callback;
}

/**
* プリペアドステートメントを実行します。
*
* @param array | Traversable パラメータ
* @return bool
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/

public function execute($parameters = null)
{
if (isset($parameters)) {
if (!is_array($parameters) && !($parameters instanceof \Traversable)) {
throw new \InvalidArgumentException(
sprintf('Parameters accepts an Array or Traversable, invalid type:%s',
(is_object($parameters))
? get_class($parameters)
: gettype($parameters)
)
);
}
foreach ($parameters as $name => $value) {
$type = \PDO::PARAM_STR;
if (is_int($value)) {
$type = \PDO::PARAM_INT;
} elseif (is_bool($value)) {
$type = \PDO::PARAM_BOOL;
} elseif (is_null($value)) {
$type = \PDO::PARAM_NULL;
}
$this->statement->bindValue(
(strncmp(':', $name, 1) !== 0) ? sprintf(':%s', $name) : $name,
$value,
$type
);
}
}

try {
return $this->statement->execute();
} catch (\PDOException $e) {
ob_start();
$this->statement->debugDumpParams();
$debug = ob_get_contents();
ob_end_clean();
throw new \RuntimeException(
sprintf('execute prepared statement failed. "%s"', $debug)
);
}
}

/**
* 結果セットから次の行を取得して返します。
*
* @param int PDO::FETCH_* 定数
* @param int PDO::FETCH_ORI_* 定数
* @param int カーソルの位置
* @return mixed 失敗した場合は false
*/

public function fetch($how = null, $orientation = null, $offset = null)
{
$result = $this->statement->fetch($how, $orientation, $offset);
if (!isset($this->callback) || $result === false) {
return $result;
}
return call_user_func($this->callback, $result);
}

/**
* IteratorAggregate::getIterator()
*
* @return Traversable
*/

public function getIterator()
{
return (isset($this->callback))
? new CallbackIterator($this->statement, $this->callback)
: new \IteratorIterator($this->statement);
}

}

ちょっと長いので、メソッドを一部抜粋して見ていきます。


Acme\PDO\PDOStatement::__call()

public function __call($name, $args)

{
if (method_exists($this->statement, $name)) {
return call_user_func_array(array($this->statement, $name), $args);
}
throw new \BadMethodCallException(
sprintf('Undefined Method "%s" called.', $name)
);
}

マジックメソッド __call() で、このクラスに定義されていないメソッドが呼ばれた際は PDOStatement のメソッドを呼びます。


Acme\PDO\PDOStatement::execute()

public function execute($parameters = null)

{
if (isset($parameters)) {
if (!is_array($parameters) && !($parameters instanceof \Traversable)) {
throw new \InvalidArgumentException(
sprintf('Parameters accepts an Array or Traversable, invalid type:%s',
(is_object($parameters))
? get_class($parameters)
: gettype($parameters)
)
);
}
foreach ($parameters as $name => $value) {
$type = \PDO::PARAM_STR;
if (is_int($value)) {
$type = \PDO::PARAM_INT;
} elseif (is_bool($value)) {
$type = \PDO::PARAM_BOOL;
} elseif (is_null($value)) {
$type = \PDO::PARAM_NULL;
}
$this->statement->bindValue($name, $value, $type);
}
}

try {
return $this->statement->execute();
} catch (\PDOException $e) {
ob_start();
$this->statement->debugDumpParams();
$debug = ob_get_contents();
ob_end_clean();
throw new \RuntimeException(
sprintf('execute prepared statement failed. "%s"', $debug)
);
}
}

execute() は配列だけでなく Traversable も受け付けるようにして、foreach() で値を見ながら適当なパラメータ種別で PDOStatement::bindValue() を呼んでいます。

これは PDOStatement::execute() の引数でパラメータの配列を渡した場合、全ての値が PDO::PARAM_STR として設定される という仕様のためですが、これを見落とすとなかなか気付きづらい問題を抱えてしまう恐れがあります。

参考

自分の使いやすさ重視でこういう仕様としていますが、PHPでは通常、HTTPリクエストやDBなど外部由来の値は明示的に変換しない限り文字列になりますので、

むしろ多少表現は冗長になっても $statement->bindValue(':userId', sprintf('%d', $userId), \PDO::PARAM_INT) という風に明示した方が、読む人には優しいかもしれません。


Acme\PDO\PDOStatement::fetch()

public function fetch($how = null, $orientation = null, $offset = null)

{
$result = $this->statement->fetch($how, $orientation, $offset);
if (!isset($this->callback) || $result === false) {
return $result;
}
return call_user_func($this->callback, $result);
}

フェッチ結果を引数として、setFetchCallback() で予めセットされたコールバックを実行します。

PDOStatement::fetch() はクエリーの実行結果が空の場合にも false が返されますので、コールバックがセットされていない場合と、false が返された場合にコールバックを実行せずそのまま返しています。


Acme\PDO\PDOStatement::getIterator()

public function getIterator()

{
return (isset($this->callback))
? new CallbackIterator($this->statement, $this->callback)
: new \IteratorIterator($this->statement);
}

IteratorAggregate::getIterator() でコールバックがセットされている場合は独自クラスの CallbackIterator を生成して返します。

コールバックがセットされていない場合は PDOStatement をそのまま返せばいいはずなのですが、愛用のテンプレートエンジン PHPTAL が繰り返し処理で Traversable ではなく Iterator を想定しているらしく、rewind() を呼ぼうとしてエラーが発生する問題に遭遇したため、IteratorIterator で包んで返すようにしました。

(ある意味 PHPTAL の不具合?のお陰で IteratorIterator の存在意義を理解できたのですが)


Acme\PDO\CallbackIterator

CallbackIterator はコードを見なくてもだいたい想像がつくと思いますが…。

<?php

namespace Acme\PDO;

/**
* Callback イテレータ
*
* @author k.holy74@gmail.com
*/

class CallbackIterator extends \IteratorIterator
{

/**
* @var callable 要素を返す際に実行するコールバック関数
*/

private $callback;

/**
* コンストラクタ
*
* @param Traversable
* @param callable 要素を返す際に実行するコールバック関数
*/

public function __construct(\Traversable $iterator, callable $callback)
{
$this->callback = $callback;
parent::__construct($iterator);
}

/**
* Iterator::current
*
* @return mixed
*/

public function current()
{
return call_user_func($this->callback, parent::current());
}

}

特にどうということもない実装です。


PDOを拡張する

ここまでの実装で PDOStatement にフェッチ結果へのコールバック機能を追加できたのですが、PDO::ATTR_STATEMENT_CLASS オプションが利用できなくなったため、PDO::prepare() や PDO::query() の戻り値をいちいち生成したオブジェクトにセットする必要があり、なかなか面倒です。

そこで PDO を継承したクラスを作成し、PDOStatement を返すメソッドをオーバーライドして解決します。

(PDO の接続はアプリケーション全体で利用されるため、振る舞いだけではなく型としても PDO とした方が良いとの判断です。使えるインタフェースもないので…)


Acme\PDO\PDO

<?php

namespace Acme\PDO;

use Acme\PDO\PDOStatement;

/**
* PDO
*
* @author k.holy74@gmail.com
*/

class PDO extends \PDO
{

/**
* prepare()
*
* @param string
* @param int
* @return Acme\PDO\PDOStatement
* @override
*/

public function prepare($statement, $options = null)
{
return new PDOStatement(parent::prepare($statement, $options ?: []));
}

/**
* query()
*
* @param string
* @param int
* @param int
* @param array
* @return Acme\PDO\PDOStatement
* @override
*/

public function query($statement, $fetchMode = null, $fetchOption = null, array $arguments = null)
{
switch (func_num_args()) {
case 4:
return new PDOStatement(parent::query($statement, $fetchMode, $fetchOption, $arguments));
case 3:
return new PDOStatement(parent::query($statement, $fetchMode, $fetchOption));
case 2:
return new PDOStatement(parent::query($statement, $fetchMode));
}
return new PDOStatement(parent::query($statement));
}

}

query() メソッドの引数が可変なのですが、ちょっとしつこい書き方でしょうか。

call_user_func_array() を使うと遅くなりそうだし、自在に可変にする必要もないので、このようにしました。

これで、こんな風に書けるようになりました。

$pdo = new \Acme\PDO\PDO('sqlite::memory:', null, null, [

\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
]);

// 中略

$now = new \DateTimeImmutable('2013-12-20 00:00:00');

$statement = $pdo->prepare("SELECT * FROM users WHERE user_id = :userId");
$statement->execute(['userId' => 1]);
$statement->setFetchMode(\PDO::FETCH_ASSOC);
$statement->setFetchCallback(function($cols) use ($now) {
return new \Acme\Domain\Data\User([
'userId' => (int)$cols['user_id'],
'userName' => $cols['user_name'],
'birthday' => new \DateTimeImmutable($cols['birthday']),
'createdAt' => new \DateTimeImmutable(sprintf('@%d', $cols['created_at'])),
'now' => $now,
'dateFormat' => 'Y-m-d',
'dateTimeFormat' => 'Y-m-d H:i:s',
]);
});

foreach ($statement as $user) {
var_dump($user->userId); // int(1)
var_dump($user->userName); // string(5) "test1"
var_dump($user->birthday); // class DateTimeImmutable...
var_dump($user->birthdayAsString); // string (10) "1980-12-20"
var_dump($user->age); // int(33)
var_dump($user->createdAt); // class DateTimeImmutable...
var_dump($user->createdAtAsString); // string (19) "2013-12-20 00:00:00"
}

標準の機能だけで実行する場合と比べるとパフォーマンスは落ちるでしょうけど、コールバックによる自由が効くのは心強いです。

サンプルでは単純にオブジェクトを生成して返しているだけですが、テーブルのメタデータやクラスに定義されたルールにもとづいて、カラム名や値を変換してオブジェクトを生成する仕組みを作れば、更に省力化できそうです。


【応用その1】JSONレスポンスを返す

Traversable を実装している PDOStatement はそのまま繰り返し処理でフェッチできるため、オブジェクトのままHTMLテンプレートまで持っていくことが多いのですが、Web APIなどのレスポンス生成においてJSONに変換したいこともあると思います。

そんな時は PHP 5.4 から導入されたインタフェース JsonSerializable を実装することで、ユーザーが意識せずとも json_encode() による変換に対応できるようになります。

PDOStatement 自身に実装しても良いのですが、オブジェクトからJSONへの変換自体は再利用する可能性が高いと考え、専用の変換クラスを作成して対応します。


Acme\JsonSerializer

<?php

namespace Acme;

/**
* JsonSerializer
*
* @author k.holy74@gmail.com
*/

class JsonSerializer implements \JsonSerializable
{

private $value;

/**
* __construct()
*
* @param mixed 変換する値
*/

public function __construct($value = null)
{
$this->value = $value;
}

/**
* __invoke()
*
* @return mixed 変換後の値
* @throws \LogicException
*/

public function __invoke($value)
{
return $this->convert($value);
}

/**
* JsonSerializable::jsonSerialize()
*
* @return mixed 変換後の値
* @throws \LogicException
*/

public function jsonSerialize()
{
return $this->convert($this->value);
}

/**
* 与えられた値をJSONで表現可能な値に変換して返します。
*
* @param mixed 変換する値
* @return mixed 変換後の値
* @throws \LogicException
*/

public function convert($value)
{
if (null === $value || is_scalar($value)) {
return $value;
}
if (is_array($value)) {
$array = [];
foreach ($value as $name => $val) {
$array[$name] = $this->convert($val);
}
return $array;
}
if (is_object($value)) {
if ($value instanceof \JsonSerializable) {
return $value->jsonSerialize();
}
if ($value instanceof \DateTime || $value instanceof \DateTimeInterface) {
return $value->format(\DateTime::RFC3339);
}
if ($value instanceof \Traversable) {
$array = [];
foreach ($value as $name => $val) {
$array[$name] = $this->convert($val);
}
return $array;
}
if ($value instanceof \stdClass) {
$object = new \stdClass;
foreach (get_object_vars($value) as $name => $val) {
$object->{$name} = $this->convert($val);
}
return $object;
}
}
throw new \LogicException(
sprintf('The value is invalid to convert JSON. type:%s',
is_object($value) ? get_class($value) : gettype($value)
)
);
}

}

配列のキーとオブジェクトのプロパティ名は命名規則が異なる(プロパティ名の先頭はアルファベットまたはアンダースコアのみ)ため、配列 → 匿名オブジェクト の変換が成立しないケースがあるので、Traversable は全て配列に詰め替えて返しています。

JsonSerializable が実装されているオブジェクトの場合は最優先で jsonSerialize() を実行しますので、複雑な構造を持つオブジェクトの場合はそのクラスに JsonSerializable を実装することで適切に変換します。

たとえば前述の例だと \Acme\Domain\Data\User クラスに JsonSerializable を実装しておくことで、以下のようなコードでJSONレスポンスを返せるようになります。

$now = new \DateTimeImmutable('2013-12-20 00:00:00');

$statement = $pdo->prepare("SELECT * FROM users");
$statement->execute();
$statement->setFetchMode(\PDO::FETCH_ASSOC);
$statement->setFetchCallback(function($cols) use ($now) {
return new \Acme\Domain\Data\User([
'userId' => (int)$cols['user_id'],
'userName' => $cols['user_name'],
'birthday' => new \DateTimeImmutable($cols['birthday']),
'createdAt' => new \DateTimeImmutable(sprintf('@%d', $cols['created_at'])),
'now' => $now,
'dateFormat' => 'Y年n月j日',
'dateTimeFormat' => 'Y-m-d H:i:s',
]);
});

header('Content-Type: application/json; charset=utf-8');
echo json_encode(
[
'users' => new \Acme\JsonSerializer($statement),
],
\JSON_HEX_TAG | \JSON_HEX_APOS | \JSON_HEX_AMP | \JSON_HEX_QUOT
);

Userクラスでの実装はこんな感じで…。


\Acme\Domain\Data\User

<?php

namespace Acme\Domain\Data;

/**
* User
*
* @author k.holy74@gmail.com
*/

class User implements \IteratorAggregate, \JsonSerializable
{

// 中略

/**
* JsonSerializable::jsonSerialize
*
* @return \stdClass for json_encode()
*/

public function jsonSerialize()
{
$object = new \stdClass;
$object->userId = $this->userId;
$object->userName = $this->userName;
$object->birthday = $this->getBirthdayAsString();
$object->createdAt = $this->getCreatedAtAsString();
$object->age = $this->getAge();
return $object;
}
}

JSONへの変換をドメイン層のクラスが行うことに違和感があるかもしれませんが、手っ取り早く対応するにはこういう方法もあるということで。


【応用その2】CSVファイル出力してみる

Traversable を実装している PDOStatement はそのまま繰り返し処理でフェッチできるため(以下略)

業務アプリケーション等で、CSVファイルとしてダウンロードさせる要件は多々あると思います。

様々な方法がありますが、ここでは PHP 5.4 から導入された SplFileObject::fputcsv() を利用してみます。

$now = new \DateTimeImmutable('2013-12-20 00:00:00');

$statement = $pdo->prepare("SELECT * FROM users");
$statement->execute();
$statement->setFetchMode(\PDO::FETCH_ASSOC);
$statement->setFetchCallback(function($cols) use ($now) {
return new \Acme\Domain\Data\User([
'userId' => (int)$cols['user_id'],
'userName' => $cols['user_name'],
'birthday' => new \DateTimeImmutable($cols['birthday']),
'createdAt' => new \DateTimeImmutable(sprintf('@%d', $cols['created_at'])),
'now' => $now,
'dateFormat' => 'Y年n月j日',
'dateTimeFormat' => 'Y-m-d H:i:s',
]);
});

$file = new \SplFileObject('php://temp', 'r+');

foreach ($statement as $user) {
$file->fputcsv([
$user->userId,
mb_convert_encoding($user->userName, 'SJIS-win', 'UTF-8'),
mb_convert_encoding($user->getBirthdayAsString(), 'SJIS-win', 'UTF-8'),
$user->getAge(),
mb_convert_encoding($user->getCreatedAtAsString(), 'SJIS-win', 'UTF-8'),
]);
}

$file->rewind();
$filestat = $file->fstat();
$filename = sprintf('ユーザー一覧(%s).csv', $now->format('YmdHi'));
header('Content-Type: text/csv');
header(sprintf('Content-Length: %d', $filestat['size']));
header(sprintf(
'Content-Disposition: attachment; filename*=utf-8\'\'%s; filename=%s',
rawurlencode($filename),
$filename
));
$file->fpassthru();

せっかく PDOStatement にコールバックを仕込めるようにしたのですが、Content-Length ヘッダを返すためにはCP932へのエンコーディング変換後のサイズを取得する必要があるため、foreach() で変換してSplFileObject を使って一時ファイル(またはメモリ)に書き込んでいます。

ただ、書き込み先がメモリのためか SplFileObject::getSize() だと "stat failed for php://temp" の RuntimeException が発生します。

SplFileObject::fstat() を試したところ書き込んだサイズを取得できたので、その値を Content-Length ヘッダで返しています。

Content-Disposition ヘッダによる日本語ファイル名の処理については、主要ブラウザの実装を確認した上で、自分の中では今のところこれが最適と考えているものです。(IE7およびそれ以前は無視するとして)

参考

なお SplFileObject::fputcsv() は実行環境における改行コードが付与される という、fputcsv() 関数と同様の動作となりますので、CR + LF で改行したい場合は、継承して書き換えるなり、ストリームフィルタを作成するなりして対応する必要がありそうです。

参考

他にも SplFileObject::fputcsv() には、 ダブルクォートのエスケープにおいて、バックスラッシュにダブルクォートが続く場合にダブルクォートがエスケープされない という問題もあります。(PHP 5.5.7 にて確認)


【参考】CSVの仕様について

CSVは2005年に RFC 4180 として仕様が公開されていますが、その内容は改行、カンマ、ダブルクォートのエスケープ仕様などから Excel の実装に準拠しているものと思われます。

ただし、長らくベンダ実装が先行していたという実情を反映してか、カテゴリは Informational とされており、厳密に守らなければいけない規約ではありません。

ソフトウェアによっては、たとえば FileMaker の出力するCSVでは、フィールド内の改行コードが垂直タブに変換される、といった罠も存在しますので要注意です。