メリークリスマス! PHPStan Advent Calendarも20日目になりましたね!
以前、PHPStan 1.6.0(2022年4月リリース)で条件付き戻り値型(Conditional return types)が使えるという話をしました。
これなんですが、戻り値(@return
)だけでなく、パラメータ(@param
)でも使えます。
問題設定
本を検索する関数を考えてみましょう。
/**
* @phpstan-param 'asc'|'desc'|'custom' $sort
* @phpstan-return list<Book>
*/
function searchBooks(string $word, string $sort, Closure $callback = null): array
{
return [];
}
- 文字列を渡して本を検索する
- ソートの種類として
'asc'
、'desc'
、'custom'
の3種類を受け付ける -
'custom'
のときだけ、クロージャを渡して何かをカスタムできるようにする
そういう関数で考えてみることにします。
設定がガバいので何をどうカスタムできるのかは決めていません。
まあ、外部の検索エンジンに投げるクエリかなんかでしょう。
sort\callback | Closure |
null |
---|---|---|
asc |
エラー | 有効 |
desc |
エラー | 有効 |
custom |
有効 | エラー |
エラーになる組み合わせはPHPUnitなどでチェックできるようにしてもいいのですが、PHPStanの機能だけでも静的にチェックできます。
解法
/**
* @phpstan-param 'asc'|'desc'|'custom' $sort
* @phpstan-param ($sort is 'custom' ? Closure : null) $callback
* @phpstan-return list<Book>
*/
function searchBooks(string $word, string $sort, Closure $callback = null): array
{
return [];
}
エラーにすべきパターンできちんとエラーになっています
-
'desc'
にClosure
を渡している -
'custom'
にnull
を渡している
もちろん、文字列定数ではなくenum
を使っても大丈夫です。
enum Sort
{
case ASC;
case DESC;
case CUSTOM;
}
/**
* @phpstan-param ($sort is Sort::CUSTOM ? Closure : null) $callback
* @phpstan-return list<Book>
*/
function searchBooks(string $word, Sort $sort, Closure $callback = null): array
{
return [];
}
もうちょっと複雑な例
では、もうちょっと複雑な例を考えてみましょう。
enum BookSearchBy
{
case AUTHOR;
case NAME;
case PUBLISHER;
}
/**
* @return list<Book>
*/
function search(BookSearchBy $query, array $query): array
{
return [];
}
検索の種類(著者名、書名、出版社)によって異なるパラメータを渡さなければいけないとしましょう。
-
BookSearchBy::AUTHOR
で渡すときは['type' => 'author', 'author' => $string]
-
BookSearchBy::NAME
で渡すときは['type' => 'name', 'name' => $string]
-
BookSearchBy::PUBLISHER
で渡すときは['type' => 'publisher', 'publisher' => $string]
高レベルな関数としては使いにくい設計ですが、外部のデータベースに問い合わせることにこのような変換を要することは、まああるんじゃないでしょうか。
つまりこうです。
/**
* @phpstan-param (
* $by is BookSearchBy::AUTHOR ? array{type: 'author', author: string} :
* $by is BookSearchBy::NAME ? array{type: 'name', name: string} :
* $by is BookSearchBy::PUBLISHER ? array{type: 'publisher', publisher: string} :
* never) $query
* @return list<Book>
*/
function search(BookSearchBy $by, array $query): array
{
return [];
}
やってみましょう。
$search_by = filter_var($_GET['search_by']);
$word = filter_var($_GET['word'], options: FILTER_NULL_ON_FAILURE) ?? '';
$books = match ($search_by) {
'author' => search(BookSearchBy::AUTHOR, [
'type' => 'author',
'author' => $word,
]),
'name' => search(BookSearchBy::PUBLISHER, [
'type' => 'name',
'name' => $word,
]),
'publisher' => search(BookSearchBy::NAME, [
'type' => 'publisher',
'publisher' => $word,
]),
default => throw new Exception(),
};
このコードではわざとBookSearchBy::PUBLISHER
とBookSearchBy::NAME
をとりちがえていますが、きちんと警告が出ているのがわかりますでしょうか。
きっちりと型がついてエラーも出ていないことがおわかりいただけますでしょうか。
現状の課題
将来的にクエリが複雑になることを見越してクエリビルダークラスに分割してみましょうか。
$params = match ($search_by) {
'author' => [BookSearchBy::AUTHOR, new AuthorQueryBuilder()],
'name' => [BookSearchBy::PUBLISHER, new NameQueryBuilder()],
'publisher' => [BookSearchBy::NAME, new PublisherQueryBuilder()],
default => throw new Exception(),
};
$books = search($params[0], $params[1]->build($word));
このコードもわざと間違っているのですが、これは残念なことに検知されていません。
俺たちの戦いはこれからだ!