Ray.MediaQuery

Ray.MediaQueryはデータベースクエリーのインターフェイスから、クエリー実行オブジェクトを生成しインジェクトします。

  • ドメイン層とインフラ層の境界を明確にします。
  • ボイラープレートコードを削減します。
  • 外部メディアの実体には無関係なので、後からストレージを変更することができます。並列開発やスタブ作成が容易です。

インストール

composer require ray/media-query

Note: Web APIを同様にインターフェイスから扱うには ray/web-query を参照してください。

利用方法

データベースアクセスするインターフェイスを定義します。

インターフェイス定義

#[DbQuery]アトリビュートでSQLのIDを指定します。

use Ray\MediaQuery\Annotation\DbQuery;

interface TodoAddInterface
{
    #[DbQuery('todo_add')]
    public function add(string $title): void;
}

モジュール設定

MediaQuerySqlModuleでSQLディレクトリとインターフェイスディレクトリを指定します。

use Ray\AuraSqlModule\AuraSqlModule;
use Ray\MediaQuery\MediaQuerySqlModule;

protected function configure(): void
{
    $this->install(
        new MediaQuerySqlModule(
            interfaceDir: '/path/to/query/interfaces',
            sqlDir: '/path/to/sql'
        )
    );
    $this->install(new AuraSqlModule(
        'mysql:host=localhost;dbname=test',
        'username',
        'password'
    ));
}

MediaQuerySqlModuleはAuraSqlModuleのインストールが必要です。

注入

インターフェイスからオブジェクトが直接生成され、インジェクトされます。実装クラスのコーディングが不要です。

class Todo
{
    public function __construct(
        private TodoAddInterface $todoAdd
    ) {}

    public function add(string $title): void
    {
        $this->todoAdd->add($title);
    }
}

DbQuery

SQL実行がメソッドにマップされ、IDで指定されたSQLをメソッドの引数でバインドして実行します。例えばIDがtodo_itemの指定ではtodo_item.sqlSQL文に['id' => $id]をバインドして実行します。

  • $sqlDirディレクトリにSQLファイルを用意します。
  • SQLファイルには複数のSQL文が記述できます。最後の行のSELECTが返り値になります。

基本形は Entity(1行をhydrate済みエンティティで受け取る)と Entity リスト(複数行をエンティティの配列で受け取る)です。連想配列・独自コレクション・ページネーション・DML 系の戻り値型はこれらの応用として段階的に紹介します。

Entity(1行)

メソッドの戻り値の型としてエンティティクラスを指定すると、SQL実行結果が自動的にそのインスタンスに変換(hydrate)されます。

interface TodoItemInterface
{
    #[DbQuery('todo_item')]
    public function getItem(string $id): Todo;
}
Constructor Property Promotion(推奨)

コンストラクタプロパティプロモーションを使うと型安全でイミュータブルなエンティティを作成できます。

final class Todo
{
    public function __construct(
        public readonly string $id,
        public readonly string $title
    ) {}
}

値はSQLの列の順序でコンストラクタ引数に位置バインドされます。カラム名(例: user_name)とプロパティ名(例: $userName)は一致している必要はありません。SELECT句の列の順番とコンストラクタ引数の順番を合わせてください。

行が見つからない可能性があるときは戻り値型に Entity|null を指定します。該当行がなければ null が返ります。

Note: コンストラクタを定義しないエンティティを使う場合、Ray.MediaQuery は PDO の FETCH_CLASS にフォールバックし、列名→プロパティ名でマッピングします(snake_case 変換は行いません)。SELECT 句の列順に依存しないため、列数が多い読み取り専用 DTO や PHP 8.4 の readonly class で有用です。

Entity リスト(複数行)

戻り値の型を array に宣言すると複数行を受け取れます。各行をエンティティに hydrate するには、@return list<Entity> の docblock を付けるか、#[DbQuery]factory: パラメーターでファクトリを指定します。

interface TodoListInterface
{
    /** @return list<Todo> */
    #[DbQuery('todo_list')]
    public function list(): array;

    #[DbQuery('todo_list', factory: TodoFactory::class)]
    public function listByFactory(): array;
}

@return list<Entity>factory: も付けない場合、各行は連想配列のまま返ります(単行版は次のtype: ‘row’を参照)。

type: ‘row’(連想配列)

戻り値の型が array のとき、デフォルトでは複数行の rowlist([['id' => '1', 'title' => 'run'], ...])として返ります。集計結果のような単一行(例: ['total' => 10, 'active' => 5])をそのまま連想配列で受け取りたい場合は type: 'row' を指定します。指定しないと、その1行は $result[0] に入ります。

interface TodoItemInterface
{
    #[DbQuery('todo_stats', type: 'row')]
    public function getStats(string $id): array;  // ['total' => 10, 'active' => 5]
}

AffectedRows(UPDATE / DELETE の影響行数)

UPDATE / DELETEの影響行数を、ただの int ではなく型付きの値で受け取るには、戻り値型にAffectedRowsを指定します。

use Ray\MediaQuery\Result\AffectedRows;

interface TodoRepositoryInterface
{
    #[DbQuery('todo_delete')]
    public function delete(string $id): AffectedRows;
}

$affected = $todoRepo->delete($id);
$affected->count;        // int — 影響を受けた行数
$affected->isAffected(); // bool — count > 0 のときtrue

SQLファイルに複数のステートメントが含まれる場合、AffectedRows最後に実行されたステートメントの結果を表します。

実行可能な例: TodoAffectedInterfaceDbQueryAffectedRowsTest

InsertedRow(INSERT の解決済み値とID)

INSERTでフレームワークが解決した値(UUID、タイムスタンプ、DateTime → SQL文字列、ToScalarInterface によるスカラー化など)と、ドライバーが採番した lastInsertId をまとめて受け取るには、戻り値型に InsertedRow を指定します。同じ SQL ID でも、戻り値型を変えるだけでフレームワークのふるまい(実行のみ/影響行数/採番ID 等)が切り替わります。

use Ray\MediaQuery\Result\InsertedRow;

interface TodoAddInterface
{
    #[DbQuery('todo_add')]
    public function add(string $title): void;

    #[DbQuery('todo_add')]
    public function addReturning(string $title): InsertedRow;
}

$inserted = $todoAdd->addReturning('ドキュメント作成');
$inserted->values;  // array<string, mixed> — ドライバーにバインドされた解決済み値
$inserted->id;      // ?string — auto-increment ID(採番されない場合はnull)

$inserted->idはドライバーがfalse / '' / '0'を返した場合、nullに正規化されます。

PostQueryInterface(独自の型付き結果)

SELECTの結果を array<Article> ではなく、published() / titles() のようなドメインメソッドを持つ独自のコレクションでラップしたいことがあります。PostQueryInterfaceを実装したクラスを戻り値型に指定すると、フレームワークはクエリ実行後の状態を PostQueryContext にまとめて静的ファクトリ fromContext() に渡し、インスタンスの組み立てはクラス側で自由に決められます。

interface PostQueryInterface
{
    public static function fromContext(PostQueryContext $context): static;
}

PostQueryContextは次の4つの readonly プロパティを持ちます:

プロパティ 用途
$statement PDOStatement 実行済みステートメント。rowCount()やカラムメタデータ等を参照可能。
$pdo ExtendedPdoInterface 接続。lastInsertId() や追加読み取りに使う。
$values array<string, mixed> ParamConverter / ParamInjector 解決後の値(UUID、タイムスタンプ、バリューオブジェクトのスカラー化等)。
$rows array<mixed> SELECT時の取得行。@return Wrapper<Entity> または factory: でエンティティが解決されると hydrate 済みエンティティ、未指定時は連想配列。DML 時は常に []
use Ray\MediaQuery\Result\PostQueryContext;
use Ray\MediaQuery\Result\PostQueryInterface;

/** @implements IteratorAggregate<int, Article> */
final class Articles implements PostQueryInterface, IteratorAggregate, Countable
{
    /** @param list<Article> $rows */
    public function __construct(public readonly array $rows) {}

    public static function fromContext(PostQueryContext $context): static
    {
        /** @var list<Article> $rows */
        $rows = $context->rows;
        return new static($rows);
    }

    public function getIterator(): ArrayIterator { return new ArrayIterator($this->rows); }
    public function count(): int { return count($this->rows); }
}

interface ArticleRepositoryInterface
{
    #[DbQuery('article_list', factory: ArticleFactory::class)]
    public function list(): Articles;
}

各行のhydrationは Entity リストと同じく、generic な @return YourWrapper<Entity> docblock または factory: で指示します。継承ではなくコンポジションで表現することで、Laravel Collection、Doctrine ArrayCollection、独自実装などを自由に内部に保持できます。

Ray.MediaQuery の実行可能な例:

  • ArticlesPostQueryContext::$rows をラップするコレクション
  • ArticlesInterface — 連想配列、docblock による hydrate、factory: による hydrate の宣言例

なお、AffectedRows / InsertedRow も同じ PostQueryInterface の実装です。DML 後に独自の集計や監査ログを伴う結果型が欲しい場合は、同じ仕組みで自作できます。

戻り値型 早見表

  1行 複数行(rowlist)
エンティティ Entity / Entity|null array + @return list<Entity> または factory:
連想配列 array + #[DbQuery(type: 'row')] array(docblock / factory: なし)

応用的な戻り値型:

  • MyCollPostQueryInterface 実装)— 独自の型付きコレクションラッパー
  • PagesInterface + #[Pager] — ページネーション
  • AffectedRows — DML の影響行数
  • InsertedRow — DML の採番ID + 解決済み値
  • void — DML の実行のみ

パラメーター

日付時刻

パラメーターにバリューオブジェクトを渡すことができます。例えば、DateTimeInterfaceオブジェクトをこのように指定できます。

interface TaskAddInterface
{
    #[DbQuery('task_add')]
    public function __invoke(string $title, DateTimeInterface $createdAt = null): void;
}

値はSQL実行時に日付フォーマットされた文字列に変換されます。

INSERT INTO task (title, created_at) VALUES (:title, :createdAt); # 2021-2-14 00:00:00

値を渡さないとバインドされている現在時刻がインジェクションされます。SQL内部でNOW()とハードコーディングする事や、毎回現在時刻を渡す手間を省きます。

テスト時刻

テストの時には以下のようにDateTimeInterfaceの束縛を1つの時刻にすることもできます。

$this->bind(DateTimeInterface::class)->to(UnixEpochTime::class);

バリューオブジェクト(VO)

DateTime以外のバリューオブジェクトが渡されるとToScalarInterfaceを実装したtoScalar()メソッド、もしくは__toString()メソッドの返り値が引数になります。

interface MemoAddInterface
{
    #[DbQuery('memo_add')]
    public function __invoke(string $memo, UserId $userId = null): void;
}
class UserId implements ToScalarInterface
{
    public function __construct(
        private readonly LoginUser $user
    ) {}
    
    public function toScalar(): int
    {
        return $this->user->id;
    }
}
INSERT INTO memo (user_id, memo) VALUES (:user_id, :memo);

パラメーターインジェクション

バリューオブジェクトの引数のデフォルトの値のnullがSQLやWebリクエストで使われることはないことに注意してください。値が渡されないと、nullの代わりにパラメーターの型でインジェクトされたバリューオブジェクトのスカラー値が使われます。

public function __invoke(Uuid $uuid = null): void; // UUIDが生成され渡される

ページネーション

#[Pager]アトリビュートでSELECTクエリーをページングできます。

use Ray\MediaQuery\Annotation\DbQuery;
use Ray\MediaQuery\Annotation\Pager;
use Ray\MediaQuery\PagesInterface;

interface TodoList
{
    #[DbQuery('todo_list'), Pager(perPage: 10, template: '/{?page}')]
    public function __invoke(): PagesInterface;
}

count()で件数が取得でき、ページ番号で配列アクセスをするとページオブジェクトが取得できます。PagesInterfaceはSQL遅延実行オブジェクトです。

$pages = ($todoList)();
$cnt = count($pages);    // count()をした時にカウントSQLが生成されクエリーが行われます。
$page = $pages[2];       // 配列アクセスをした時にそのページのDBクエリーが行われます。

// $page->data           // sliced data
// $page->current;       // 現在のページ番号
// $page->total          // 総件数
// $page->hasNext        // 次ページの有無
// $page->hasPrevious    // 前ページの有無
// $page->maxPerPage;    // 1ページあたりの最大件数
// (string) $page        // ページャーHTML

SqlQuery

SqlQueryはSQLファイルのIDを指定してSQLを実行します。実装クラスを用意して詳細な実装を行う時に使用します。

class TodoItem implements TodoItemInterface
{
    public function __construct(
        private SqlQueryInterface $sqlQuery
    ) {}

    public function __invoke(string $id): array
    {
        return $this->sqlQuery->getRow('todo_item', ['id' => $id]);
    }
}

get* メソッド

SELECT結果を取得するためには取得する結果に応じたget*を使います。

$sqlQuery->getRow($queryId, $params);        // 結果が単数行
$sqlQuery->getRowList($queryId, $params);    // 結果が複数行
$statement = $sqlQuery->getStatement();       // PDO Statementを取得
$pages = $sqlQuery->getPages();              // ページャーを取得

Ray.MediaQueryはRay.AuraSqlModuleを含んでいます。さらに低レイヤーの操作が必要な時はAura.SqlのQuery BuilderやPDOを拡張したAura.SqlのExtended PDOをお使いください。doctrine/dbalも利用できます。

パラメーターインジェクションと同様、DateTimeInterfaceオブジェクトを渡すと日付フォーマットされた文字列に変換されます。

$sqlQuery->exec('memo_add', [
    'memo' => 'run',
    'created_at' => new DateTime()
]);

他のオブジェクトが渡されるとtoScalar()または__toString()の値に変換されます。

Ray.InputQueryとの連携

BEAR.ResourceでRay.InputQueryを利用している場合、InputクラスをMediaQueryのパラメーターとして直接渡すことができます。

use Ray\InputQuery\Attribute\Input;

final class UserCreateInput
{
    public function __construct(
        #[Input] public readonly string $name,
        #[Input] public readonly string $email,
        #[Input] public readonly int $age
    ) {}
}
interface UserCreateInterface
{
    #[DbQuery('user_create')]
    public function add(UserCreateInput $input): void;
}

InputオブジェクトのプロパティがSQLパラメータに自動展開されます。

-- user_create.sql
INSERT INTO users (name, email, age) VALUES (:name, :email, :age);

この連携により、ResourceObjectからMediaQueryまで一貫して型安全なデータフローを実現できます。

プロファイラー

メディアアクセスはロガーで記録されます。標準ではテストに使うメモリロガーがバインドされています。

public function testAdd(): void
{
    $this->sqlQuery->exec('todo_add', $todoRun);
    $this->assertStringContainsString(
        'query: todo_add({"id":"1","title":"run"})',
        (string) $this->log
    );
}

独自のMediaQueryLoggerInterfaceを実装して、各メディアクエリーのベンチマークを行ったり、インジェクトしたPSRロガーでログをすることもできます。

PerformSqlInterface

PerformSqlInterfaceを実装することで、SQL実行部分を完全にカスタマイズできます。デフォルトの実行処理を独自の実装に入れ替えることで、より高度なログ機能、パフォーマンス監視、セキュリティ制御などを実現できます。

use Exception;
use Ray\MediaQuery\PerformSqlInterface;

final class CustomPerformSql implements PerformSqlInterface
{
    public function __construct(
        private LoggerInterface $logger
    ) {}

    #[Override]
    public function perform(ExtendedPdoInterface $pdo, string $sqlId, string $sql, array $values): PDOStatement
    {
        $startTime = microtime(true);
        
        // カスタムログ出力
        $this->logger->info("Executing SQL: {$sqlId}", [
            'sql' => $sql,
            'params' => $values
        ]);
        
        try {
            /** @var array<string, mixed> $values */
            $statement = $pdo->perform($sql, $values);
            
            // 実行時間のログ
            $executionTime = microtime(true) - $startTime;
            $this->logger->info("SQL executed successfully", [
                'sqlId' => $sqlId,
                'execution_time' => $executionTime
            ]);
            
            return $statement;
        } catch (Exception $e) {
            $this->logger->error("SQL execution failed: {$sqlId}", [
                'error' => $e->getMessage(),
                'sql' => $sql
            ]);
            throw $e;
        }
    }
}

カスタム実装を使用するには、DIコンテナで束縛します:

use Ray\MediaQuery\PerformSqlInterface;

protected function configure(): void
{
    $this->bind(PerformSqlInterface::class)->to(CustomPerformSql::class);
}

SQLテンプレート

SQLの実行時にクエリーIDを含むカスタムログを出力して、スローログ分析時にどのクエリーが実行されたかを特定しやすくすることができます。

MediaQuerySqlTemplateModuleを使用して、SQLログのフォーマットをカスタマイズできます。

use Ray\MediaQuery\MediaQuerySqlTemplateModule;

protected function configure(): void
{
    $this->install(new MediaQuerySqlTemplateModule("-- App: .sql\n"));
}

利用可能なテンプレート変数:

  • {{ id }}: クエリーID
  • {{ sql }}: 実際のSQL文

デフォルトテンプレート:-- {{ id }}.sql\n{{ sql }}

この機能により、実行されるSQLにクエリーIDがコメントとして含まれ、データベースのスローログを分析する際に、どのアプリケーションのどのクエリーが実行されたかを容易に特定できます。

-- App: todo_item.sql
SELECT * FROM todo WHERE id = :id