🎻

Symfony + Ray.MediaQueryという可能性

に公開

Symfony Advent Calendar 2025 の1日目の記事です!🎄✨

Twitter (X) でもちょいちょいSymfonyネタを呟いてます。よろしければ フォロー お願いします🤲

はじめに

Ray.MediaQuery は、Javaの Doma に似たコンセプト(だそうです)のデータベースアクセスフレームワークです。

先日 PHPカンファレンス香川2025 の懇親会で、作者である @koriym さんによる紹介LTを拝聴し、そのコンセプトに強く興味を持ったので、普段使っているSymfonyとの統合について軽くPoCしてみました。

本稿では、その内容と率直な感想をシェアしたいと思います。

PoCのコードベースは以下のGitHubリポジトリで公開しています。

https://github.com/ttskch/symfony-ray-media-query-poc

その前に:Ray.MediaQueryについて基本原理を簡単に紹介

まずは実際の利用イメージをご覧ください。以下は、Ray.MediaQueryのGitHubリポジトリのREADMEで紹介されているサンプルコードです。それぞれに簡単な解説を付記します。

interface OrderRepository
{
    #[DbQuery('order_detail', factory: OrderDomainFactory::class)]
    public function getOrder(string $id): Order;
}
  • これがDB操作を集約したリポジトリの定義
  • MVCのコントローラーなど利用側のコードでは、DIによって OrderRepository を受け取り、その getOrder() メソッドを呼ぶことでDBクエリを実行し、結果をオブジェクトの形で受け取る
  • OrderRepository はインターフェースとして宣言するだけでよく、実装を書く必要はない
  • getOrder() メソッドなどDBクエリを実行させたいメソッドには #[DbQuery] アトリビュートを付与する
  • #[DbQuery] アトリビュートに渡している 'order_detail' という文字列から、(設定されているディレクトリ直下の)order_detail.sql というSQLファイルが読み込まれ、そのクエリが実行される
  • getOrder() メソッドの引数 string $id の値は、DBクエリ実行時に order_detail.sql のSQL内で使用されているパラメーター :id にバインドされる
  • #[DbQuery] アトリビュートの factory 引数で指定されている OrderDomainFactory が、DBクエリの結果を Order オブジェクトに変換するためのファクトリーとなる
// Factory injects services and enriches data from SQL
class OrderDomainFactory
{
    public function __construct(
        private TaxService $taxService,
        private InventoryService $inventory,
        private RuleEngine $rules,
    ) {}

    public function factory(string $id, float $subtotal): Order
    {
        return new Order(
            id: $id,
            subtotal: $subtotal,
            tax: $this->taxService->calculate($subtotal),
            canShip: $this->inventory->check($id),
            rules: $this->rules,
        );
    }
}
  • #[DbQuery] アトリビュートの factory 引数に指定された OrderDomainFactory は、DBクエリ実行後にRay.Aopの効果によって factory() メソッドが自動で呼び出される
  • factory() メソッドの引数 string $id, float $subtotal は、DBクエリの結果セットの列名に対応している
    • 例えばこの場合なら、SQLが SELECT id, subtotal FROM order WHERE id = :id などとなっていることが想定される
  • DI(この場合はコンストラクタインジェクション)によってサービスを受け取ることもできるため、複雑なビジネスロジックをファクトリーに集約できる
  • ちなみに、#[DbQuery] を付与したメソッドがオブジェクト単体ではなくオブジェクトのコレクションを取得する内容であっても、factory() メソッドはオブジェクト単体を組み立てる実装でよい
    • #[DbQuery] を付与したメソッドの戻り値の型( array かどうか)などによって自動で判定される
    • #[DbQuery] アトリビュートの type 引数で明示もできる
// Domain object with business logic
class Order
{
    public function __construct(
        public string $id,
        public float $subtotal,
        public float $tax,
        public bool $canShip,
        private RuleEngine $rules,
    ) {}

    public function getPriority(): string
    {
        return $this->rules->calculatePriority($this);
    }
}
  • ファクトリーがオブジェクトのコンストラクタに、DIによって受け取ったサービスをそのまま渡すこともできるので、オブジェクトは複雑なビジネスロジックを持った「ドメインオブジェクト」として存在できる

Ray.MediaQueryは、ORMに対するアンチテーゼです。

GitHubリポジトリのREADMEなどの公式ドキュメントからピックアップしたRay.MediaQueryのコンセプトの要点は以下のとおりです。

  • SQLはSQLのまま、オブジェクトはオブジェクトのまま
  • ORMのようにSQLを抽象化して見えなくする代わりに、SQLを最大性能で活かしながらOOPの原則を維持する
  • SQL、ファクトリー、ドメインオブジェクトをそれぞれ独立してテスト可能
  • この独立性・透明性はAIによるアシストも最大化する(ORMの複雑な抽象化レイヤーはAIにとってブラックボックスだったが、Ray.MediaQueryではすべてが明示的)

さらなる詳細については以下の公式ドキュメントなどを参照してください。

0. 集計機能を持つ簡単なSymfonyアプリを用意する

PoCにあたり、まずはSymfony + Doctrineで集計機能を持つ簡単なアプリを用意しました。

  • 売上・ユーザー・チームというエンティティがある
  • 売上には1人のユーザーが紐づく
  • 集計画面では、ある日付におけるあるチーム(に所属するユーザー)の売上を絞り込んで一覧化できる

少しSQLをややこしくしたかったので、

  • ユーザーはチーム所属履歴を持っている
  • 集計画面では、指定された日付において所属していたチーム を軸に検索する

という仕様にしました。

この時点のコードは以下のとおりです。

1. Doctrineの代わりにRay.MediaQueryを使うようにしてみる

この状態から、アプリケーションレイヤーでDoctrineを使うのを一切やめて、すべてのDBクエリをRay.MediaQueryで行うようにしてみました。

https://github.com/ttskch/symfony-ray-media-query-poc/commit/3201d2bfbd85c4f4cd91087e01e70ce40e68372a

エンティティは 完全なPOPOになり、コントローラアクションにあったDoctrineクエリビルダーによるSQL組み立てコードは純粋なSQLファイルにアウトソースされました。

PhpStormなど DataGrip 相当のDBツールがバンドルされているJetBrains IDEなら、SQLファイルはIDE上で直接実行できるのでかなり開発体験がよいです。

Ray.MediaQueryはRay.Diに依存しており、 Ray.Diのコンテナ上で動作するため、Symfonyアプリケーションから利用するためには自前で上手いことブリッジする必要がありました。

また、Symfonyに備わっているDoctrineインテグレーションのおかげでほとんど無意識に使えていた (ちょっとした)便利な機能を封印されるのはちょっとストレスでした。

例えば、

とかです。

それから、もう少しダメージが大きいものとして、Symfony Profiler でDBクエリのログが見られなくなるというのがありました。ファイルなどへのログ出力は多分簡単に追加実装できると思うのですが、Symfony Profilerで他の情報と一緒にパッとGUIで見られるのは結構大きいので。

ただ、よくよく考えてみるとSymfony ProfilerでDoctrineのタブを見るときって、N+1を解消したいときとか、クエリビルダーの生成したSQLを確認・実行してみたいときとかぐらいなので、Ray.MediaQueryを導入したらそもそも見る必要がなくなるかも、という気はしてきました。

あと、純粋なSQLを手に入れる代わりに、自明だけど無駄に長いSQL も自分(かAI)で書かなければいけません。リレーションシップが大量にあって必要なネストもそこそこ深いようなエンティティがあったらかなりしんどそうな気がしました。

ファクトリーのコード も、リレーションシップを解決するためだけにやたらコードを書く必要があり少ししんどいです。また、Ray.MediaQueryの公式ドキュメントに書かれているサンプルコードを真似して一度JSONにする方法をとりましたが、これだとすべてが mixed になってしまうので、真面目に型を解決しようと思ったら型注釈を大量に書かなければならず厳しいです(今回は baselineに吐き出して無視しました)。JSONでなくJOINベースで書くほうがよさそうかもしれません(factory() メソッドの引数は大量になってしまいますが)。

2. 基本的にはDoctrineを使いつつ、複雑なSQLにのみRay.MediaQueryを使うようにしてみる

Doctrineを完全に手放すのがちょっとストレス大きそうな感じもあったので、複雑なSQLが必要になる場面でのみ、部分的にRay.MediaQueryを使うというパターンを試してみました。

https://github.com/ttskch/symfony-ray-media-query-poc/commit/bc9379a830d8eab99b578cc59a866384991e0378

もしSQLが相当に複雑なら、クエリビルダーでやるよりはRay.MediaQueryを導入して生SQLで管理できるほうが保守しやすいとは思うけど、そうでなければあまり旨味がなさそうな感じがしました。

なお、ファクトリーで作ったエンティティをUnit of Workに登録するために、ファクトリーにDoctrineのエンティティマネージャーを注入する必要があり、これにやや強引なワークアラウンドが必要でした。

最初、Ray.Diの仕様を誤認していてなかなか上手く対応できなかったので、恥を忍んで作者の 郡山さんに相談 させていただき、色々と的確なアドバイスをいただきました。
さらに、より宣言的な方法でSymfonyコンテナとRay.Diコンテナを統合する 改善案 まで頂き、勉強させていただきました。

3. 複雑なSQLを、Ray.MediaQueryではなくDoctrineのNativeQueryを使うようにしてみる

生SQLの結果をもとにDoctrineエンティティを自前で組み立てるぐらいなら、初めからDoctrineの NativeQuery を使うでいいのでは、と思ったのでやってみました。

https://github.com/ttskch/symfony-ray-media-query-poc/commit/c89e4f0148c70ecb0c81f3f2c398ae6da191144c

このコミットでは、Ray.MediaQueryを完全に削除して、単に集計画面のクエリの管理を以下の方法で行うように変更しました。

これぐらいのユースケースなら、無理にRay.MediaQueryを統合するよりも素直にDoctrineのNativeQueryを使うほうが楽そうです。

感想

というわけで、いくつかのパターンを試してみました。

実際に触ってみる前は、複雑なSQLだけをRay.MediaQueryで置き換えるという使い方から入るのがいいかなと想像していましたが、そのケースだと意外と旨みが少ないかもしれません。

NativeQueryを使ってもしんどくなるような、もっと複雑な集計ロジックになってくるとまた話が変わってくるかもしれません。とはいえ、そこまで複雑な場合は普通、NativeQueryで直接エンティティを生成するのは諦めて、DBALで連想配列を取得して必要に応じてIDでエンティティを再取得するといった使い方をすると思うので、やはり複雑なSQLだけを置き換えるというのでは本領を発揮できないかもしれません。

今回PoCしてみて、個人的には、Symfonyプロジェクトに導入するとしてもやっぱり「完全置き換えパターン」かなと思いました。

  • ファクトリーとドメインオブジェクトにビジネスロジックを集約できる
  • すべてのビジネスロジックが単体テスト可能になる

という2点が、「Doctrine前提の便利機能が使えない」というデメリットを補って余りあるインパクトをもたらしそうな予感がします。
プロジェクトの規模や性質と相談しながら、今後実務への投入を検討したいなと思いました。

おわり。

Symfony Advent Calendar 2025明日は空きです🥺どなたかぜひご参加ください!
@akky さんが埋めてくださいました!!!

https://akky.hatenablog.com/entry/2025/12/03/023723

GitHubで編集を提案

Discussion