PDOのフェッチモードを使い分けて、取得後の整形ループを消す
PDOのフェッチモードとは、SQLの結果セットをPHP側でどんな形に組み立てて受け取るかを指定する設定です。FETCH_GROUPやFETCH_KEY_PAIRを使うと、取得したあとに書きがちな整形ループをまるごと省けます。
PDOを使っていると、fetchAll(PDO::FETCH_ASSOC) だけで一生を終えてしまいがちです。私もしばらくそうでした。取ってきた配列をforeachで回して、IDをキーにした連想配列に組み直す。あの数行、実はPDO側が最初からやってくれる場合が多いんですね。
今日はその「整形ループを消す」ためのフェッチモードの話をします。
なぜフェッチモードを使い分けるのか
典型的なのはこういうコードです。マスタ表からIDと名前を引いてきて、あとで引けるようにマップを作る。
$rows = $pdo->query('SELECT userid, name FROM users')
->fetchAll(PDO::FETCH_ASSOC);
$map = [];
foreach ($rows as $row) {
$map[$row['userid']] = $row['name'];
}
悪くはないのですが、この4行は「2列のSELECTを連想配列にする」という、あまりにも定型的な処理です。定型的なものはライブラリ側に寄せたほうが、読む人が中身を追わなくて済みます。
キーと値のペアが欲しいときはどう書く?
2列だけのSELECTなら、PDO::FETCH_KEY_PAIR が一発で同じ結果を返します。
$map = $pdo->query('SELECT userid, name FROM users')
->fetchAll(PDO::FETCH_KEY_PAIR);
// [104 => 'Chris', 105 => 'Jamie', 107 => 'Robin']
これで、さっきのforeachは消えます。条件が2つあって、そこだけ注意が必要です。結果セットの列がちょうど2列でないといけないこと、そして第1列が一意でないと値が失われること。マニュアルにも「第1列が一意でない場合、どの値が失われるかは未定義」と明記されています。ユニークキーやプライマリキーを第1列に置く、というのが実質的な使用条件です。
あと、このモードは fetchAll() でしか意味を持ちません。1行ずつの fetch() で使おうとしないほうがいいです。
IDで引ける連想配列はFETCH_UNIQUE
3列以上あって、第1列をキーにして残りを配列で持ちたい。これは PDO::FETCH_UNIQUE の出番です。単体では形が決まらないので、PDO::FETCH_ASSOC などと組み合わせて使います。
$users = $pdo->query('SELECT userid, name, country FROM users')
->fetchAll(PDO::FETCH_UNIQUE | PDO::FETCH_ASSOC);
// [
// 104 => ['name' => 'Chris', 'country' => 'Ukraine'],
// 105 => ['name' => 'Jamie', 'country' => 'England'],
// ]
ここでハマるのが、キーに使われた第1列が値の側から消えることです。$users[104]['userid'] は存在しません。中の配列にもIDを残したいなら、SQLで同じ列を2回書きます。
SELECT userid, userid, name, country FROM users
やや不格好ですが、公式マニュアルでも紹介されている正攻法です。私はこれを知らずに「PDOのバグでは」と疑った時期がありました。仕様です。
グループ化はFETCH_GROUPに任せる
「国ごとにユーザーをまとめる」のようなグループ化も、PHP側でループを書かずに済みます。PDO::FETCH_GROUP は第1列をキーにして、同じキーの行をリストにまとめてくれます。
$byCountry = $pdo->query('SELECT country, userid, name FROM users')
->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_ASSOC);
// [
// 'Ukraine' => [
// ['userid' => 104, 'name' => 'Chris'],
// ['userid' => 108, 'name' => 'Sean'],
// ],
// 'England' => [
// ['userid' => 105, 'name' => 'Jamie'],
// ],
// ]
FETCH_UNIQUEと同じく、第1列は値の側から消えます。残したければ列を2回書くのも同じです。
値が1列だけでいいなら、PDO::FETCH_COLUMN と組み合わせると2次元で済みます。
$names = $pdo->query('SELECT country, name FROM users')
->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_COLUMN);
// ['Ukraine' => ['Chris', 'Sean'], 'England' => ['Jamie']]
ちなみに FETCH_GROUP と FETCH_UNIQUE を両方立てても意味がありません。どちらも同じ第1列を見にいくので、組み合わせても片方が潰れるだけです。マニュアルにもそう書かれています。
FETCH_CLASSはコンストラクタが後から呼ばれる
ここが一番の落とし穴だと思っています。PDO::FETCH_CLASS は「先にプロパティを埋めてから、コンストラクタを呼ぶ」という順番で動きます。直感と逆です。
class User
{
public $userid;
public $name;
public function __construct()
{
$this->name = 'anonymous';
}
}
$stmt = $pdo->query('SELECT userid, name FROM users');
$users = $stmt->fetchAll(PDO::FETCH_CLASS, User::class);
// name はすべて 'anonymous'。DBの値がコンストラクタで上書きされている
コンストラクタで初期値を入れているクラスだと、DBから取った値が黙って消えます。エラーも警告も出ないので、気づくまでに時間がかかるタイプのバグですね。
順番を逆にしたいときは PDO::FETCH_PROPS_LATE を足します。
$users = $stmt->fetchAll(
PDO::FETCH_CLASS | PDO::FETCH_PROPS_LATE,
User::class
);
// コンストラクタが先に走り、そのあとDBの値が入る
FETCH_CLASSを使うなら、PROPS_LATEをセットで書く癖をつけておくくらいでちょうどいいと思います。
もうひとつ、地味に効いてくる話があります。SELECTした列名に対応するプロパティがクラスに無い場合、PDOは動的にプロパティを生やします。この挙動は現在は非推奨で、PHP 9.0からはエラーになるとマニュアルに書かれています。SELECT * でクラスに詰める書き方は、そろそろ見直しておいたほうが安全です。
readonlyな値オブジェクトにはFETCH_FUNC
コンストラクタ昇格とreadonlyでガチガチに固めたクラスに、FETCH_CLASSで値を流し込もうとすると、だいたいうまくいきません。readonlyプロパティは宣言したクラスのスコープの中でしか初期化できないので、外から代入するPDOのやり方とは相性が悪いのです。FETCH_INTOに至っては「プロパティはpublicでなければならず、readonlyは不可」とマニュアルに明記されています。
こういうときは PDO::FETCH_FUNC のほうが素直です。各行の列値がそのまま引数として渡ってくるので、自分でコンストラクタを呼べます。
final class User
{
public function __construct(
public readonly int $id,
public readonly string $name,
) {
}
}
$users = $pdo->query('SELECT userid, name FROM users')->fetchAll(
PDO::FETCH_FUNC,
static fn ($userid, $name) => new User((int) $userid, (string) $name),
);
引数は列名ではなく「SELECTの並び順」で渡ってきます。列名との対応は取れないので、SQLの列順とコールバックの引数順を必ず揃えてください。ここがずれても静かに動いてしまうのが怖いところです。ドライバによっては数値列が文字列で返ってくるので、上のようにキャストを挟んでおくと安心です。
なお FETCH_FUNC も fetchAll() 専用です。そして FETCH_GROUP や FETCH_UNIQUE と併用した場合、グループ化のほうが先に適用されてから関数が呼ばれます。
まとめ
PDOのフェッチモードは、要するに「SQLの結果をどう組み立てるか」をPDOに委譲する仕組みです。2列ならKEY_PAIR、IDで引きたいならUNIQUE、まとめたいならGROUP。この3つを覚えるだけでも、コードから整形用のforeachがかなり減ります。
そしてFETCH_CLASSの順番の話は、知らないと必ず一度は踏むやつです。プロパティが先、コンストラクタが後。逆にしたければPROPS_LATE。ここだけは覚えて帰ってもらえるといいなと思います。
よくある質問
Q. FETCH_KEY_PAIRで3列以上を取るとどうなりますか?
A. このモードは結果が2列であることを前提にしています。列数が合わない場合はエラーになるため、SELECTする列を2つに絞ってください。
Q. FETCH_UNIQUEやFETCH_GROUPで、キーにした列を値の側にも残せますか?
A. 残せます。SQLでその列を2回書く(例: SELECT userid, userid, name FROM users)のが公式に紹介されている方法です。
Q. FETCH_CLASSでコンストラクタに引数を渡せますか?
A. 渡せます。fetchAll(PDO::FETCH_CLASS, User::class, [$arg1, $arg2]) のように、第3引数へコンストラクタ引数の配列を指定します。
Q. FETCH_GROUPは1行ずつのfetch()でも使えますか?
A. 使えません。FETCH_GROUP、FETCH_UNIQUE、FETCH_FUNCはいずれも fetchAll() 専用のモードです。