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つ。

  1. SQL を自分で書ける
  2. 戻り値の各カラムに PHPStan が型を付けられる
  3. 実行時の値もその型に合っている(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 の TypeStringResolverType オブジェクトに変換している。TypeStringResolver は extension.neon に class: を書いておけば DI で自動注入される。自分で IntegerTypeUnionType を組むより、この方が対応表を文字列のまま共有できて楽だった。

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.neonrules: セクションを追加して登録している。型拡張とルールで「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

カテゴリ