PHPでDBから取得したデータの型情報が欲しいのでプログラムを生成するコードを書いた

phpstan 0.12 の level:max に対応させようとしてプログラムがかなり変わった。

自分で思った以上にDBから取得したデータを`stdClass`で持ち回していたみたい。
そうすると phpstan がまったく使えないので、DBのテーブル情報をPHPのクラスに変換するコードを書いた。

以前は自作ORMを使っていてDBのテーブルを解析して`eval`してクラスを作ってたんだけど、最近は素のPDOでいいやってなってた。
でもORMのようなものが必要になって、結局同じようなことになってしまうんだなあ、などと思ったり。

```php
#!/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:member_trait.php
<?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:member_join_trait.php
<?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:member.php
<?php
namespace App\Generated\Db;

class member
{
    use member_trait;

    public function __construct()
    {
    }
}
```

結構大量に作る。

配列型を使っているなら

```php:functions.php
/** @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の配列に変換するコードも生成する。

何が嬉しいかというと、

```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良い。

実際には

```php
/**
 * @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`にも対応できる。

```sql
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に対応するクラスは

```php
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);
        }
    }
}
```

```php
$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`を追加するなど）もう少し細かくチェックするようにした。