Help us understand the problem. What is going on with this article?

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

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を作る。

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;
}
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;
}
member.php
<?php
namespace App\Generated\Db;

class member
{
    use member_trait;

    public function __construct()
    {
    }
}

結構大量に作る。

配列型を使っているなら

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の配列に変換するコードも生成する。

何が嬉しいかというと、

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away