tehilim に「PHPStan で戻り値を検査できる生 SQL」を入れた
自作の PHP 向け DB ツールキット tehilim に、生 SQL を書いて実行し、その戻り値を PHPStan で検査できる仕組みを入れた。この記事はその設計メモと、途中で踏んだ CI の罠、Copilot レビューとのやり取りの記録。
tehilim とは
Prisma 風のスキーマファイルから、型付きのクエリクライアントを生成する PHP ライブラリ。エンティティクラスは作らず、行は連想配列で返す。その代わり @phpstan-type で配列の形を宣言しておくので、PHPStan や IDE が「この行にはどのキーがあって、それぞれ何型か」を正確に把握できる。
$found = $db->user->findUnique(['where' => ['email' => '[email protected]']]);
echo $found['name']; // PHPStan: string|null
echo $found['nope']; // PHPStan エラー: そんなキーは無い
select を渡したときに戻り型を絞る PHPStan 拡張も同梱している。ここまでは既存の話。
課題: 生 SQL に降りると型が消える
クエリビルダで表現できない JOIN や集約が必要になると、これまでは $client->driver->pdo() で PDO を直接触るしかなかった。そうすると戻ってくるのは array<string, mixed> で、せっかく積み上げた型の恩恵がそこで途切れる。
欲しかったのは次の3つ。
- SQL を自分で書ける
- 戻り値の各カラムに PHPStan が型を付けられる
- 実行時の値もその型に合っている(
DateTimeカラムが文字列で返ってくる、みたいなズレが無い)
設計: shape を1回書くと、実行時と静的解析の両方に効く
PHP には「型を値として渡す」手段が無い。かといって SQL 文字列を静的に解析してスキーマから型を推論する方式は、パーサをライブラリ側で持つ必要があり、JOIN、エイリアス、方言固有の式で簡単に壊れる。
そこで、結果カラムの型を 配列リテラルで宣言する 方式にした。
$rows = $db->queryRaw(
'SELECT u.id, u.name, COUNT(p.id) AS posts, MAX(p."createdAt") AS latest
FROM "User" u LEFT JOIN "Post" p ON p."authorId" = u.id
GROUP BY u.id, u.name',
[],
['id' => 'int', 'name' => '?string', 'posts' => 'int', 'latest' => '?DateTime'],
);
// PHPStan: list<array{id: int, name: string|null, posts: int, latest: DateTimeImmutable|null}>
foreach ($rows as $row) {
$row['posts']; // int
$row['latest']?->format('Y-m-d'); // DateTimeImmutable|null
$row['nope']; // PHPStan エラー
}
この第3引数を shape と呼んでいる。ポイントは、同じ配列が2つの役割を持つこと。
- 実行時: 各カラムを
Driver::cast()に通す。DateTimeならDateTimeImmutableに、boolならboolに変換される。これは生成クライアントがスキーマのカラムに使っているのと同じキャスト処理 - 静的解析: PHPStan 拡張がリテラルの shape を読んで
list<array{...}>を組み立てる
型タグは既存の columnTypes() と同じ語彙にした。int BigInt float bool string bytes DateTime json mixed と、nullable を示す ? 接頭辞。対応表は RawShape::TYPES という定数1箇所に置き、実行時も拡張もそこを参照する。ここが二重管理になると「PHPStan は int と言っているのに実行時は string」というズレが生まれるので、単一の表にすることにはこだわった。
shape 有りのときの厳格さ
shape を渡したら、結果は shape そのものになるようにした。
- SELECT が返しても shape に無いカラムは落とす
- shape にあるのに SELECT が返さないカラムは、カラム名を含む
RuntimeException - 未知のタグは実行前に
InvalidArgumentException
PHPStan 側は「shape に無いキーへのアクセス」を検出し、実行時側は「shape のタイポ」を検出する。両方向で早く失敗する。
入口は3つ
shape 必須だと面倒なので、用途別に3つ用意した。
// 1. ad-hoc な射影。shape で型を付ける
$db->queryRaw($sql, $params, $shape);
// 2. モデルの行を独自 SQL で取る。モデルのカラム型でキャストされ list<UserRow> になる。shape 不要
$db->user->queryRaw('SELECT * FROM "User" WHERE name LIKE ? ORDER BY id', ['A%']);
// 3. 書き込み / DDL。影響行数を返し、リクエストキャッシュをフラッシュする
$db->executeRaw('UPDATE "Post" SET published = :p WHERE "authorId" = :a', ['p' => true, 'a' => 1]);
「複雑な WHERE を書きたいだけ」なら 2 で足りる。JOIN や集約でモデルの形に当たらないときだけ 1 を使う。shape 無しで queryRaw を呼ぶこともできて、その場合は従来通り list<array<string, mixed>> に戻る。
PHPStan 拡張の中身
戻り型拡張
DynamicMethodReturnTypeExtension を実装し、BaseClient::queryRaw の呼び出しを見る。第3引数(または名前付き引数 shape:)の型を Scope::getType() で取り、ConstantArrayType が1つに定まり、キーも値もすべて定数文字列なら、そこから array{...} を組み立てる。
各値の PHP 型は RawShape::phpDocType($tag) で 'int|null' のような PHPDoc 文字列にしてから、PHPStan の TypeStringResolver で Type オブジェクトに変換している。TypeStringResolver は extension.neon に class: を書いておけば DI で自動注入される。自分で IntegerType や UnionType を組むより、この方が対応表を文字列のまま共有できて楽だった。
shape が動的だったり、タグが未知だったりした場合は narrow せずデフォルトの型を返す。嘘の型を付けるより「分からない」と言う方がいい。
タグのタイポを検出するルール
型拡張だけだと、'itn' と書いたときに黙って list<array<string, mixed>> に戻るだけで、実行するまで気付けない。なので Rule<MethodCall> も1つ足した。
Unknown tehilim raw type tag 'itn' for column 'id'.
💡 Known tags: int, BigInt, float, bool, string, bytes, DateTime, json, mixed. Prefix with ? for nullable.
extension.neon に rules: セクションを追加して登録している。型拡張とルールで「queryRaw の呼び出しか」「shape 引数はどれか」の判定が重複するので、その部分は小さなヘルパークラスに寄せた。
テスト
PHPStan には拡張をテストするための基底クラスがある。
TypeInferenceTestCase: フィクスチャ内のassertType('list<array{id: int}>', $rows)を集めて検証するRuleTestCase: フィクスチャを解析して、期待するエラーメッセージと行番号を照合する
型推論のフィクスチャは11ケース、ルールは正常系・異常系・無関係なクラスの queryRaw を1ファイルで検証。実行時側は SQLite インメモリで生成クライアントを作る統合テストを9ケース書いた。さらに、生成されたクライアントと利用コードを PHPStan level 9 + bleedingEdge でも通して、生成コード側が新しい厳しさに耐えることを確認した。
CI が落ちた: PHPStan 2.2 と composer.lock
ローカルで全ゲートを通して PR を出したら、CI の PHPStan ジョブだけ落ちた。エラーは今回触っていないドライバ層の6箇所で、内容はどれも同じ。
Method SqliteDriver::listTables() should return list<string> but returns array<string>.
原因は環境差だった。このリポジトリは composer.lock を gitignore しているので、CI は毎回最新の依存を解決する。ローカルの PHPStan は 2.1.55、CI は 2.2.13。2.2 系では PDOStatement::fetchAll() の結果に array_map() をかけたものを list と見なさなくなっていて、main にもともとあったコードが引っかかった。
ローカルの PHPStan を 2.2.13 に上げて再現させ、6箇所を array_values() で包んで解決。修正そのものは機械的だが、「PR と無関係なファイルが CI で落ちる」状況はレビュアーを混乱させるので、別コミットに分けてメッセージに理由を書いた。
lock を commit しない運用は、ライブラリでは珍しくない。ただ、静的解析ツールのマイナーアップデートで main が赤くなり得ることは今回はっきりしたので、lock を入れるか制約を狭めるかは別途決める。
Copilot レビューとのやり取り
PR に GitHub Copilot の自動レビューが付いていて、2ラウンドで計5件の指摘が来た。全部妥当だった。
1ラウンド目
- README の
executeRawの例で、?と:authorのプレースホルダを混在させていた。PDO はこれを許さないので実行時に落ちる。日英両方の README で同じミスをしていた RawShape::validate()がタグしか検査しておらず、['int', 'string']のようなリスト形式を渡すと後段で「column '0' が無い」という分かりにくいエラーになる。キーが文字列であることを先に検査するよう修正doQueryRaw()の docblock が「モデルのカラムを返す SELECT」と保証しているように読める。実際には SELECT の中身を検査していないので、list<UserRow>の型は呼び出し側のカラム指定に依存する、と明記した
2ラウンド目
- キーの検査を入れたら、次は値。
['id' => 123]のようにタグが文字列でないと、strict_types下でisKnown(string $tag)が TypeError を投げる。is_string()を先に見て、カラム名とget_debug_type()の結果を含むInvalidArgumentExceptionにした
ドキュメントの例が実行時に壊れているのは、自分では気付きにくいタイプのミスなので助かった。一方、指摘が「入力検証をもう一段深く」の方向に連鎖するのは自動レビューらしい癖で、どこで止めるかは自分で判断する必要がある。今回は「public な入口で受け取る配列の形は、実行前に全部検査する」という線で止めた。
まとめ
- 生 SQL の戻り値に型を付けるには、SQL を解析するより「結果の形を宣言してもらう」方が堅い
- 宣言は1箇所にして、実行時キャストと静的型の両方をそこから導く。二重管理はズレの温床
- PHPStan 拡張は型拡張だけでなくルールも組み合わせると、タイポを実行前に止められる
- composer.lock を commit しないなら、静的解析ツールの更新で CI が落ちる覚悟が要る
PR は polidog/tehilim#13。