PHP用ORM Emonkak\Ormの概要

背景

データベースを利用したWebアプリケーションをインフラストラクチャ(技術的機能)の観点から単純化すると、クライアントのリクエストを入力に、データベースに格納されたデータをCRUD(Create/Read/Update/Delete)して、その結果をレスポンスとして出力するものと定義できる。

この定義からいうと、リクエストとレスポンスの入出力を管理するのがWAF(Web Application Framework)で、データベースに格納されたデータをCRUDするのがORM(Object-relational mapping)となる。 両者はWebアプリケーションを構成するインフラストラクチャのコアであり、特にORMはドメインモデルの設計にも大きな影響を与えるのでその重要性はより高い。

そのような中、ORMといえばRuby On RailsのActive Recordに代表されるような重厚長大なものが主流だ。 一方で、ドメインモデルを特定のインフラストラクチャ(ORM)に依存することを避けるという観点からは、よりシンプルで依存性の少ないORMの方が望ましい。

ドメインの関心とインフラストラクチャの関心を適切に分離して、それぞれの関心に注力することでシンプルかつ明瞭な設計を実現する。 そのために新しいORMが必要だった。

コンセプト

Emonkak\Ormはフレームワークではなくライブラリとして設計した。

ここで言うフレームワークとライブラリの違いは以下の点にある:

ここでフレームワークとしてのORMの問題を考えてみる。

よくある例としては、ドメインモデル(エンティティ)の実装にORMが提供する基底クラスを継承しなければならないということがある。 これでは、ドメインとインフラストラクチャの関心が混ざり合い、適切に分離するのが難しい。 ORMが提供するモデルとドメインモデルを別個のものとして設計するという考えもあるが、冗長な実装になりがちでデメリットの方が大きいだろう。

制御の逆転を利用したフレームワークの難点としては、単純にわかりづらいという問題がある。 どのように機能が呼び出されるのかは、フレームワークの動作を知らなければわからないからだ。

そこで、Emonkak\Ormはあくまでインフラストラクチャの実装を助けるためのライブラリであると位置付け、ドメインの設計に影響を与えないようにした。 いわゆるリポジトリの実装に使うための一つのライブラリに過ぎないということだ。

特徴

Emonkak\Ormはクエリビルダー、リレーション、オブジェクトへのマッピングと基本的な機能しか提供しない。 データベースの抽象はEmonkak\Databaseが、結果セットのシーケンス処理はEmonkak\Enumerableと別のライブラリによって提供されるものを利用する。

  • SELECT, INSERT, UPDATE, DELETE文のクエリビルダー
  • カスタマイズ可能なマッピング動作
  • 一対一、一対多、多対多のリレーション
  • アクセスされるまで読み込みが遅延される遅延リレーション
  • PSR-6 Caching Interfaceを利用したキャッシュ付きのリレーション
  • ページネーション

使い方

※すべて開発中のものなので変更可能性がある

基本的な使い方としてはクエリビルダーを作成してクエリを構築、用途に合った実行メソッドを呼ぶ形になる。

SELECT文を発行して結果セットを取得する

クエリの実行結果を得るための結果セットを取得するには getResult() メソッドを呼び出す。 getResult() 実行時点ではまだクエリは実行されておらず、foreach で走査するなどして、実際に結果を得ようとして初めてクエリは実行される。 結果セットは Emonkak\Enumerable\EnumerableInterface を実装しているので、例えば first() メソッドを呼び出すことで一件だけ取り出すことができる。

<?php

use Emonkak\Database\PDOInterface;
use Emonkak\Orm\Fetcher\PopoFetcher;
use Emonkak\Orm\SelectBuilder;

$users = (new SelectBuilder())
    ->from('users')
    ->getResult($pdo /** @var PDOInterface */, new PopoFetcher(User::class));

$user = (new SelectBuilder())
    ->from('users')
    ->where('user_id', '=', 1)
    ->getResult($pdo /** @var PDOInterface */, new PopoFetcher(User::class))
    ->first();

ここで PopoFetcher というのが出てきたが、これは Emonkak\Orm\Fetcher\FetcherInterface の実装クラスで、 Emonkak\Database\PDOStatementInterface からどのように値を取得するのかを決めるものだ。 PopoFetcher の場合は指定されたクラスのインスタンスを作成して、それをPOPO(Plain Old PHP Object)としてプロパティに値を代入する。 POPOとはPOJO(Plain Old Java Object)PHP版で、マジックメソッドなどを利用しないごく普通のPHPオブジェクトを表す。

POPOの実装例:

<?php

class User
{
    private $user_id;
    private $first_name;
    private $last_name;

    public function getUserId()
    {
        return $this->user_id;
    }

    public function getFirstName()
    {
        return $this->first_name;
    }

    public function getLastName()
    {
        return $this->last_name;
    }
}

FetcherInterface の別の実装としては指定されたクラスのコンストラクター連想配列を与えてインスタンスを作成する ModelFetcher と、オブジェクトにマッピングせずに連想配列をそのまま返す ArrayFetcher の実装がある。 ModelFetcherActiveRecordタイプのORMにあるようなModelクラスを利用したい場合に役立つ。

ページネーションを利用する

指定された件数ごとにページ分けされた結果セットを取得するには paginate() メソッドを呼び出す。

<?php

use Emonkak\Database\PDOInterface;
use Emonkak\Orm\Fetcher\PopoFetcher;
use Emonkak\Orm\SelectBuilder;

$userPages = (new SelectBuilder())
    ->from('users')
    ->where('user_id', '=', 1)
    ->paginate($pdo /** @var PDOInterface */, new PopoFetcher(User::class), 100 /** @var integer ページ分けの単位 */);

$users = $userPages->at(1);  // 1ページ目を取得
$userPages->getNumItems();  // 総アイテム数を取得
$userPages->getNumPages();  // 総ページ数を取得

更新系のクエリを実行する

結果行がない更新系のクエリを実行する際は execute() メソッドを利用する。

<?php

use Emonkak\Database\PDOInterface;
use Emonkak\Orm\UpdateBuilder;

$stmt = (new InsertBuilder())
    ->into('users', ['first_name', 'last_name'])
    ->values(['foo', 'bar'])
    ->execute($pdo /** @var PDOInterface */);

$stmt = (new UpdateBuilder())
    ->table('users')
    ->set('first_name', 'foo')
    ->set('last_name', 'bar')
    ->execute($pdo /** @var PDOInterface */);

$stmt = (new DeleteBuilder())
    ->from('users')
    ->where('user_id', '=', 1)
    ->execute($pdo /** @var PDOInterface */);

簡易クエリビルダー

Emonkak\Orm\Sql クラスはSQL文字列と、プレースホルダーにバインドする値のペアを表すクラスだが、簡易的なクエリビルダーとしても機能する。 また、 Sql オブジェクトを各種クエリビルダーのメソッドに渡すことで、クエリビルダー単体では実現できないような複雑なクエリも表現できる。

<?php

use Emonkak\Database\PDOInterface;
use Emonkak\Orm\Fetcher\PopoFetcher;
use Emonkak\Orm\Relation\Relations;
use Emonkak\Orm\SelectBuilder;
use Emonkak\Orm\Sql;

$users = (new Sql('SELECT'))
    ->append('*')
    ->append('FROM')
    ->append('users')
    ->append('WHERE')
    ->append('user_id IN')->appendValues([1, 2, 3])
    ->getResult($pdo /** @var PDOInterface */, new PopoFetcher(User::class));

// SQLの断片を条件式に指定することもできる
$users = (new SelectBuilder())
    ->from('users')
    ->where(new Sql('user_id = ?', [1]))
    ->getResult();

リレーションを利用する

リレーションは SelectBuilder あるいは Sql クラスの with() メソッドを利用することで行う。 with() に与えるリレーションは Emonkak\Orm\Relation\Relations が提供するファクトリメソッドで作成できる。

リレーションはネスト可能で、Emonkak\Orm\Relation\RelationInterfacewith() メソッドを使うことでネストできる。

作成できるリレーションの種類としては以下のものがある:

  • Relations::oneToOne(): 一対一
  • Relations::oneToMany(): 一対多
  • Relations::manyToMany(): 多対多
  • Relations::lazyOneToOne(): 遅延一対一
  • Relations::lazyOneToMany(): 遅延一対多
  • Relations::cachedOneToOne(): キャッシュ付きの一対一
  • Relations::cachedOneToMany(): キャッシュ付きの一対多

遅延リレーションについては別記事にて詳しい記述がある。

リレーション作成時に指定するパラメータは以下ものがある:

  • $tableName: string

    リレーション先のテーブル名。

  • $relationKey: string

    リレーション先のオブジェクトを格納するプロパティ名。

  • $outerKey: string

    リレーション元のテーブルの結合対象のカラム名

  • $innerKey: string

    リレーション先のテーブルの結合対象のカラム名

  • $connection: Emonkak\Database\PDOInterface

    リレーション先のテーブルのデータを取得するためのDBコネクション。 リレーションごとに指定するので、それぞれのリレーション先のテーブルが別のDBにあっても良い。

  • $fetcher: Emonkak\Orm\Fetcher\FetcherInterface

    リレーションのクエリの結果からどのように値を取得するかを決定する FetcherInterfaceインスタンス。 リレーションごとに指定するので、全く別のクラスか、あるいは連想配列をも混在してリレーションさせることもできる。

  • $proxyFactory: ProxyManager\Factory\LazyLoadingValueHolderFactory (Optional)

    遅延リレーションを利用する場合のプロキシオブジェクトを作成するためのファクトリ。

  • $cachePool: Psr\Cache\CacheItemPoolInterface

    キャッシュ付きのリレーションを利用する場合のキャッシュプール

  • $cachePrefix: string

    キャッシュ付きのリレーションを利用する場合のキャッシュキーのプレフィックス。

  • $lifetime: integer

    キャッシュ付きのリレーションを利用する場合のキャッシュの有効期限。

  • $builder: Emonkak\Orm\SelectBuilder (Optional)

    リレーション先のテーブルからデータを取得するためのクエリを生成するクエリビルダー。 リレーションする条件を特別に指定したい場合に利用する。

以下はユーザー(User)が投稿(Post)と、それに対するコメント(Comment)を持っているというリレーションの例だ:

<?php

use Emonkak\Database\PDOInterface;
use Emonkak\Orm\Fetcher\PopoFetcher;
use Emonkak\Orm\Relation\Relations;
use Emonkak\Orm\SelectBuilder;

$hasPosts = Relations::oneToMany(
    'posts',
    'posts',
    'user_id',
    'user_id',
    $pdo /** @var PDOInterface */,
    new PopoFetcher(Post::class)
);

$hasComments = Relations::oneToMany(
    'comments',
    'comments',
    'post_id',
    'post_id',
    $pdo /** @var PDOInterface */,
    new PopoFetcher(Comment::class)
);

$users = (new SelectBuilder())
    ->with($hasPosts->with($hasComments))
    ->from('users')
    ->where('user_id', '=', 1)
    ->getResult($pdo /** @var PDOInterface */, new PopoFetcher(User::class));

おわりに

Web開発の世界は長らくフルスタックのフレームワークが主流で、小さなライブラリを組合せてアプリケーションを構築するという文化があまりなかった。 しかし、PHPの世界ではPRR-7 HTTP message interfaceの登場以降、様々なソフトウェアが登場し、小さなものを組合せて大きなアプリケーションを作るというUnix哲学にも通ずる考えが徐々に普及してきた。

さらに近年はDDD(Domain-Driven Design)の考えが広く浸透つつあるのもあってか、アプリケーションのアーキテクチャについてさまざまな論考が出てきている。 アーキテクチャはアプケーションの構成部品をどう分割し、どう組み合せるかということで決まる。 そのような中、構成部品の組み合わせ方に制限を加えてしまうようなフレームワークは、アーキテクチャの決定にも制限を与えてしまうだろう。

そこで、アプリケーションの要件に適合するフレームワークを、小さなのライブラリの組み合わせで独自に構築するというのが一つの考えだ。 Emonkak\Ormはこの考えを元に開発されたライブラリである。