phpstan 0.12 の level:max に対応させようとしてプログラムがかなり変わった。
自分で思った以上にDBから取得したデータをstdClass
で持ち回していたみたい。
そうすると phpstan がまったく使えないので、DBのテーブル情報をPHPのクラスに変換するコードを書いた。
以前は自作ORMを使っていてDBのテーブルを解析してeval
してクラスを作ってたんだけど、最近は素のPDOでいいやってなってた。
でもORMのようなものが必要になって、結局同じようなことになってしまうんだなあ、などと思ったり。
# !/usr/bin/php
<?php
define('NS', 'App\\Generated\\Db');
define('TARGET', 'src/Generated/Db/');
define('TRAIT_SUFFIX', '_trait');
define('NULLABLE_SUFFIX', '_join');
define('DSN', 'host=db-host dbname=db user=dbuser password=secret');
$db = pg_connect($dsn);
$res = pg_query($db, 'select relname from pg_stat_user_tables order by relname');
foreach (pg_fetch_all($res) as $row){
echo 'parse ' . $row['relname'], "\n";
$data = parse($db, $row['relname']);
}
echo "done.\n";
function parse($db, $table)
{
$cols = pg_meta_data($db, $table);
$ns = "<?php\n\nnamespace " . NS . ";\n\n";
$class = $table;
$trait = $table . TRAIT_SUFFIX;
$join = $table . NULLABLE_SUFFIX . TRAIT_SUFFIX;
$data = $ns;
$data .= "trait $trait\n{\n";
foreach ($cols as $col => $meta)
$data .= parseCol($col, $meta);
$data .= "}\n";
file_put_contents(TARGET . $trait . '.php', $data);
$data = $ns;
$data .= "trait $join\n{\n";
foreach ($cols as $col => $meta){
$meta['not null'] = false;
$data .= parseCol($col, $meta);
}
$data .= "}\n";
file_put_contents(TARGET . $join . '.php', $data);
$data = $ns;
$data .= "class $class\n{\n";
$data .= " use $trait;\n\n";
$data .= " public function __construct()\n";
$data .= " {\n";
foreach ($cols as $col => $meta){
$data .= parseConstruct($col, $meta);
}
$data .= " }\n";
$data .= "}\n";
file_put_contents(TARGET . $class . '.php', $data);
}
function parseCol($col, $meta)
{
$data = ' /** @var ';
$data .= getColType($meta) . " */\n";
$data .= ' public $' . $col . ";\n";
return $data;
}
function getColType($meta)
{
$type = $meta['type'];
$type = ltrim($type, '_'); // array
switch ($type){
case 'int4':
case 'int4':
case 'int8':
case 'numeric':
$type = 'int';
break;
case 'bool':
$type = 'bool';
break;
default:
$type = 'string';
break;
}
if ($meta['array dims']){
$type = 'array<int,' . $type . '>';
}
if (!$meta['not null'])
$type = '?' . $type;
return $type;
}
function parseConstruct($col, $meta)
{
$data = '';
$t = ' ';
if ($meta['array dims']){
$data .= "$t/** @var ";
if (!$meta['not null'])
$data .= '?';
$data .= "string */\n";
$data .= "$t\$a = \$this->$col;\n";
if (!$meta['not null'])
$data .= "${t}if (\$a)\n ";
$data .= "$t\$this->$col = fromDbArr(\$a);\n";
}
return $data;
}
ちょっと長いけど全部貼る。
実行すると下記のようなクラスとtrait
を作る。
<?php
namespace App\Generated\Db;
trait member_trait
{
/** @var int */
public $member_id;
/** @var string */
public $email;
/** @var ?string */
public $email_mobile;
/** @var string */
public $name;
}
<?php
namespace App\Generated\Db;
trait member_join_trait
{
/** @var ?int */
public $member_id;
/** @var ?string */
public $email;
/** @var ?string */
public $email_mobile;
/** @var ?string */
public $name;
}
<?php
namespace App\Generated\Db;
class member
{
use member_trait;
public function __construct()
{
}
}
結構大量に作る。
配列型を使っているなら
/** @return int[] */
function fromDbArr(?string $dbArr): array
{
if ($dbArr === null)
return [];
$dbArr = trim($dbArr, '{}');
if (strlen($dbArr) == 0)
return [];
$arr = explode(',', $dbArr);
$arr = array_map(function($a){ return trim($a, '"'); }, $arr);
return $arr;
}
このような変換の関数を用意すると、コンストラクタで配列文字列をPHPの配列に変換するコードも生成する。
何が嬉しいかというと、
use \App\Generated\Db\member;
/** @return PDOStatement<member> */
function getMembers(PDO $pdo)
{
$stmt = $pdo->prepare('select * from member order by member_id');
$stmt->execute();
$stmt->setFetchMode(PDO::FETCH_CLASS, member::class, []);
return $stmt;
}
foreach (getMembers($pdo) as $member){
echo $member->name; // ok
echo $member->phone; // phpstan error
}
型チェックが効くようになる。
Generics良い。
実際には
/**
* @template T
* @param array<mixed> $params
* @param class-string<T> $cls
* @return PDOStatement<T>
*/
function select(PDO $pdo, string $sql, array $params, string $cls)
{
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$stmt->setFetchMode(PDO::FETCH_CLASS, $cls, []);
return $stmt;
}
foreach (select($pdo, $sql, [], member::class) as $member){
echo $member->name; // ok
echo $member->phone; // phpstan error
}
このように汎用的にも作れるので、固定で型を書かなくても大丈夫。
trait
があるのでleft join
にも対応できる。
select *
from member
join member_type using(member_type_id)
left outer join last_logged_in_log using(member_id)
join lateral (
select json_agg(member_address) as member_address_json
from member_address
where member.member_id = member_address.member_id
) as member_address on true
where email = ?
このようなSQLに対応するクラスは
namespace App\Dto;
use App\Generated\Db as Gen;
class MemberDetail
{
use Gen\member_trait;
use Gen\member_type_trait;
use Gen\last_logged_in_log_join_trait;
/** @var ?string */
public $member_address_json;
/** @var Gen\member_address[] type hint of \stdClass */
public $member_addresses = [];
public function __construct()
{
if ($this->member_address_json){
// type hint cast
/** @var Gen\member_address[] */
$this->member_addresses = json_decode($this->member_address_json);
}
}
}
$members = select($pdo, $sql, ['user@example,com'], App\Dto\MemberDetail::class);
このように書ける。
$members
はphpstanによって型チェックが効く。
これで各画面ごとに微妙に異なるleft outer join
や一対多をまとめたデータに対しても、全ての型がほぼ正確になる。
安心して手書きSQLが書ける。
いやORM使えよっていうのは置いといて。
むしろORMより型が正確なんじゃないかと思ったり。
2019-12-21 追記:
pg_meta_data
では numeric の桁数が取れないので
http://tihiro.hatenablog.com/entry/2017/08/25/080151
こちらのSQLを若干変更して(udt_name
を追加するなど)もう少し細かくチェックするようにした。