s平面の左側

左側なので安定してます(制御工学の話は出てきません)

【PHP】PDO, Eloquent, Doctrine ORM で生 SQL で select した結果をオブジェクトにマッピングする方法それぞれ

この記事は PHP Advent Calendar 2023 の9 日目の記事です!

qiita.com

フレームワークに備わっているようなクエリビルダや ORMは便利なものです。

一方で「この SQL 文はクエリビルダの機能では表現できないな」「長年使われてきた秘伝の SQL 文があってな......」といったケースで生の SQL 文を使わざるを得ない、というケースがあるかもしれません。

そこでいくつかの PHP の ORM(等)において、生の SQL での select した結果をオブジェクトにマッピングする方法をまとめていきます。

要件

次のような形式の users という単一テーブルからデータを取得することを考えます。

CREATE TABLE `users` (
    `id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    `name` VARCHAR(255) NOT NULL,
    `created_at` DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci;
mysql> select * from users;
+----+-------+---------------------+
| id | name  | created_at          |
+----+-------+---------------------+
|  1 | Alice | 2023-10-01 09:00:00 |
|  2 | Bob   | 2023-10-15 12:00:00 |
|  3 | Carol | 2023-10-31 15:00:00 |
+----+-------+---------------------+
3 rows in set (0.00 sec)

条件は以下の通りとします。

  • 生の SQL を実行し、アクティブレコードやクエリビルダ、リポジトリなどは使わない
  • 取得結果(複数行)を定義した User というクラスにマッピングし、プロパティとしてカラムの値にアクセスできる
  • DATETIME 型のカラムは DateTimeImmutable (またはそれを継承したクラス)に変換する
  • (できれば)必要なカラムが欠けている場合は例外を送出する

PDO(素の PHP)の場合(一例)

www.php.net

PDO それ自体はデータアクセスのインターフェイスを提供するクラスです。 基本的にはオブジェクトにマッピングする部分は自前で実装しなければならず*1 、一意にアプローチが決まるものではありません。

今回は、私が過去の ISUCON 参考実装移植で採用したアプローチに基づいた例を示します。

blog.okashoi.net

クラス定義

fromRow() という名前の、自身のインスタンスを生成する static メソッドを定義しています。

引数として、カラム名がキーの連想配列が与えられることを想定しており、データ検証も行っています。

<?php

readonly class User
{
    public function __construct(
        public string name,
        public ?int $id = null,
        public ?DateTimeImmutable $createdAt = new DateTimeImmutable(),
    )

    /**
     * @param array<string, mixed> $row
     * @throws UnexpectedValueException
     */
    public static function fromRow(array $row): User
    {
        if (
            !isset(
                $row['id'],
                $row['name'],
                $row['created_at'],
            )
        ) {
            throw new UnexpectedValueException('missing required column(s)');
        }

        try {
            $createdAt = new DateTimeImmutable($row['created_at']);
        } catch (Exception $e) {
            throw new UnexpectedValueException($e->getMessage(), previous: $e);
        }

        try {
            return new User(
                id: $row['id'],
                name: $row['user_id'],
                createdAt: $createdAt,
            );
        } catch (TypeError $e) {
            throw new UnexpectedValueException($e->getMessage(), previous: $e);
        } 
    }
}

実行手順

上記の通り、fromRow() はカラム名をキーとした配列が渡されることを想定しているため、 fetch() する際には PDO::FETCH_ASSOC を指定します。

<?php

//----------------------
// DB 接続を確立
//----------------------
$db = new PDO(/* 接続情報等を指定 */);

//----------------------
// データ取得 & バインド
//----------------------
$stmt = $db->query('select * from users');
/** @var list<User> $users */
$users = [];
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
    $users[] = User::fromRow($row);
}

var_dump($users);

メリット

すべて PHP に組み込まれている機能で完結しているため、依存関係が少なくて済みます。 上記の例を実行するのには Composer さえ要りません。

依存関係が少ないということは、時間が経っても(ライブラリのアップデート等によって)壊れにくいコードである、ということです。

また、全ての処理が明示的に書かれているため、細かく挙動をカスタマイズしやすいのもメリットかもしれません。

デメリット

すべての処理を明示的に書かなければならないため、コードの記述量は多くなってしまいます。

運用・メンテナンスしていくにせよ、処理の共通化などのカスタマイズをするにせよ、一定の設計と実装のスキルが必要です。

なにより、大抵のケースが「車輪の再発明」にあたります。

Eloquent の場合

Eloquent は Laravel に含まれている ORM です。

laravel.com

Eloquent 単体で使うには illuminate/database のインストールが必要です。

composer require illuminate/database

Eloquent 自体のメリット・デメリットは本記事の主旨から外れるので触れません(他で散々語られていると思うので)。

クラス定義

公式ドキュメントに従って、Model の挙動を指定するプロパティを定義します。

laravel.com

データを表すプロパティは定義しません(定義するとうまく動きません)。 ここでは静的解析のために phpDcoumentor で情報を補っています。

<?php

use Illuminate\Database\Eloquent\Model;

/**
 * @property int $id
 * @property string $name
 * @property DateTimeImmutable $created_at
 * @method static list<User> hydrate(array $items)
 */
class User extends Model
{
    protected $primaryKey = 'id';
    protected $fillable = ['name'];
    protected $casts = [
        'created_at' => 'immutable_datetime',
    ];
    public $timestamps = false;
}

実行手順

DB 接続を確立するにはグローバルに作用し、static メソッドでアクセスします。 この点が他 2 手法(DB 接続に関するクラスのインスタンスを生成する)と大きく異なります。

<?php

require __DIR__ . '/../vendor/autoload.php';

use Illuminate\Database\Capsule\Manager as DatabaseManager;

$databaseManager = new DatabaseManager();
$databaseManager->addConnection(/* 接続情報等を指定 */);
$databaseManager->setAsGlobal();
$databaseManager->bootEloquent();

select クエリの実行には select() メソッドを使用します。 結果は配列として返ってくるため、hydrate() メソッドでオブジェクトにマッピングします。

<?php

require __DIR__ . '/../vendor/autoload.php';

use Illuminate\Database\Capsule\Manager as DB;

$rows = DB::select('select * from users');

$users = User::hydrate($rows);

var_dump($users);

メリット

コードの記述量は 3 手法の中で最も少なくて済みます。

static メソッドでアクセスできるため、インスタンスの生成と引き渡しのことを考えなくて済むのはメリットといえばメリットかもしれません(想定していないところから DB にアクセスする処理を書けてしまう、というデメリットでもある)。

デメリット

User クラスのプロパティは動的(仮想的)につくられており、言語の仕組みに則った型定義はできません。 静的解析の際には PHPDocumentor の @property に頼る必要があります。 同様の原因で、hydrate() した結果のオブジェクトのプロパティに値が入っていることは保証されません。 場合によっては別途プロパティの存在チェックを実装する必要があるでしょう。

また、User::hydrate() メソッドは(User クラスの継承元である) Illuminate\Database\Eloquent\Model には実装されておらず、__callStatic() を経由して動的に Illuminate\Database\Eloquent に実装されているメソッドが呼び出されています。

このように内部の仕組みが複雑なため、IDE Helper 等のメタデータによる支えが無いと静的解析や IDE がうまく機能しません。

メリット/デメリットともに Laravel らしい、と言えましょう。

Doctrine ORM の場合

Doctrine ORM は Doctrine Project におけるコアとなるパッケージであり、Symfony でも採用されている ORM です。

www.doctrine-project.org

使うには doctrine/orm のほか、symfony/cacheのインストールが必要です*2

composer require doctrine/orm symfony/cache

クラス定義

クラス自体は何も継承する必要はなく、カラムの情報はメタデータとして付与します。 PHP 8.0 以降であれば Attributes を使って付与できます。

<?php

use Doctrine\ORM\Mapping\{ Column, Entity,  GeneratedValue, Id };

#[Entity]
readonly class User
{
    #[Id, Column(name: 'id', type: 'integer', generated: 'INSERT'), GeneratedValue]
    public int $id;
    #[Column(name: 'name', type: 'string')]
    public string $name;
    #[Column(name: 'created_at', type: 'datetime_immutable')]
    public DateTimeImmutable $createdAt;
}

実行手順

公式ドキュメントに従って ResultSetMapping を構築し、クエリ実行時に渡します。

www.doctrine-project.org

<?php

require __DIR__ . '/../vendor/autoload.php';

use Doctrine\DBAL\DriverManager;
use Doctrine\ORM\{ EntityManager, ORMSetup };
use Doctrine\ORM\Query\ResultSetMappingBuilder;

$config = ORMSetup::createAttributeMetadataConfiguration([/* メタデータの付与のしかた等を指定 */]);

$connection = DriverManager::getConnection(/* 接続情報等を指定 */, $config);

$db = new EntityManager($connection, $config);

$rsm = new ResultSetMappingBuilder($db);
$rsm->addRootEntityFromClassMetadata(User::class, 'users');

$db->beginTransaction();
$query = $db->createNativeQuery('select * from users', $rsm);
/** @var list<User> $users */
$users = $query->getResult();
$db->commit();

var_dump($users);

メリット

テーブルとの結びつきは Attribute によって行われるため、User クラスが何も継承していない POPO(Plain Old PHP Object)になります*3。 PDO のメリットでも述べたとおり、依存関係が小さく、時間が経っても壊れにくいコードと言えます。

今回の要件の範囲外ですが、ResultSetMapping という層を 1 枚挟むことにより select 句の内容にあわせてマッピング方法を柔軟に変えられます。

マッピングをオブジェクト側ではなくデータ取得側で指定するというは、他の 2 手法と異なるアプローチです。 オブジェクトとデータを疎結合にする、という目的を達する面では合理的に思えます。

デメリット

データ取得の際に、クエリの実行だけでなく ResultSetMapping の構築という 2 つのステップを踏む必要があり、少し煩雑です。

またそれに伴い、カラム定義のほかに ResultSetMapping の生成方法も理解しなければなりません。

まとめ

3 手法をざっくり比較してみるとそれぞれ

  • すべてを自らの手で書く PDO
  • 独自の "魔術" によってコードの記述量を減らせる Laravel
  • 設計的に理にかなっている Doctrine ORM

といった印象でした。

普段自身が使っている ORM でどうするのか、はもちろん、各手法のメリット・デメリットを比較してどうするかを考えてみるといいかもしれませんね。

今回は select の結果を扱いましたが、暇があれば insert や update を扱う方法も検証してまとめたいと思います。

明日、10 日目のPHP Advent Calendar 2023 の記事は、San Q さんによる「PHPの奇妙なincrement/decrementの挙動と8.3以降の挙動について」です!

参考資料

*1:PDO::FETCH_CLASS なんてものもありますが今回の要件にはそぐわないので割愛

*2:少なくとも私が検証した限りでは symfony/cache をインストールせずに、doctrine/orm を使う方法に辿り着けませんでした。自動で依存関係に入らないということは、回避する手段もありそうですが......。

*3:Attribute が依存関係とも言えますが