diff --git a/BDR_PATTERN-ja.md b/BDR_PATTERN-ja.md
index c019f64..91b361d 100644
--- a/BDR_PATTERN-ja.md
+++ b/BDR_PATTERN-ja.md
@@ -114,7 +114,7 @@ BDRパターンは重要なことを達成します:**SQL基盤による真の
- **明確な意図** - クエリ(データ読み取り)とコマンド(データ変更)の分離
```php
-// ドメインオブジェクトでのDIの力を活用
+// 読み取り側の問いにDIの力を活用
final readonly class UserDomainObject
{
public function __construct(
@@ -125,7 +125,7 @@ final readonly class UserDomainObject
private PermissionService $permissionService,
) {}
- // 注入されたサービスによる動的なビジネスルール
+ // 注入されたサービスによる読み取り側の問い
public function canEdit(Document $document): bool
{
// ORMエンティティでは不可能 - 外部サービスに依存
@@ -139,7 +139,7 @@ final readonly class UserDomainObject
}
```
-BDRパターンでは、オブジェクトは単なるデータコンテナではなく、ビジネスロジックを含むドメインオブジェクトです。これらはビジネスドメインに関する質問に答えますが、データベース自体を変更することはありません。
+BDRパターンでは、オブジェクトは単なるデータコンテナではなく、振る舞いを持つ読み取り側のドメインオブジェクトです。現在の projection やユーザー体験に関する問いに答えますが、状態変更前の最終判断は Command モデルが行います。
## 実装ガイド
@@ -250,13 +250,13 @@ final readonly class OrderDomainObject
public float $tax, // 地域別計算済み税額
public float $shipping, // 計算済み送料
public float $total, // 総合計
- public bool $canFulfill, // 適用済みビジネスルール
+ public bool $canFulfill, // 読み取り側ルールの結果
public array $insufficientStockItems, // 在庫不足商品リスト
- // 注入されたビジネスルールエンジン - ORMでは不可能
+ // 注入された読み取り側ルールエンジン - ORMでは困難
private BusinessRuleEngine $ruleEngine,
) {}
- // ドメインオブジェクトの振る舞い
+ // 読み取り側ドメインオブジェクトの振る舞い
public function getDisplayTotal(): string
{
return '$' . number_format($this->total, 2);
@@ -277,12 +277,12 @@ final readonly class OrderDomainObject
return $this->status === 'pending';
}
- public function canProcess(): bool
+ public function canShowProcessAction(): bool
{
return $this->canFulfill && $this->isPending();
}
- // 注入されたサービスによる動的なビジネスルール
+ // 注入されたサービスによる読み取り側の優先度分類
public function getBusinessPriority(): string
{
// ORMエンティティでは不可能 - 外部サービスに依存
@@ -315,7 +315,7 @@ BDRパターンの重要な利点の一つは、**テストがシンプルで信
1. **SQLクエリ**:入力に対して正しいデータを返すか?
2. **ファクトリー**:データを正しくドメインオブジェクトに変換するか?
-3. **ドメインオブジェクト**:ビジネスルールを正しく実装しているか?
+3. **ドメインオブジェクト**:読み取り側の振る舞いを正しく実装しているか?
これらが個別に正しければ、組み合わせは必然的に正しくなります。**論理的構造です。**
@@ -400,7 +400,7 @@ class OrderDomainObjectTest extends TestCase
// 振る舞いをテスト
$this->assertEquals('$2,660.00', $order->getDisplayTotal());
$this->assertEquals(8.0, $order->getTaxRate());
- $this->assertTrue($order->canProcess());
+ $this->assertTrue($order->canShowProcessAction());
$this->assertFalse($order->hasInsufficientStock());
}
}
@@ -608,54 +608,52 @@ SQL(宣言的、集合ベース)とOOP(命令的、オブジェクトベ
BDRパターンでは、それぞれが自身の領域で優秀さを発揮しながら、共により大きなものを構築します。
-## FAQ & アーキテクチャのヒント
+## FAQ
### Q: 変更されたオブジェクトをどうやってDBに書き戻す(保存する)のですか?
-**A: 書き戻しません。** BDRパターンのオブジェクトは読み取り専用であり、データのクエリのために存在します。データを変更する必要がある場合:
+**A: BDR の読み取りオブジェクトを保存するのではなく、明示的な書き込み経路を使います。** BDRパターンのオブジェクトは、画面、帳票、API レスポンス、ユースケースに合わせた読み取り側の projection です。オブジェクト自身は保存しません。
-1. **アプリケーション層でビジネス判断を行う**
-2. **コマンドを発行する** - 明確で明示的な書き込み操作
-3. **シンプルな書き込みクエリを実行** - UPDATE、INSERT、DELETE文
-
-これは**CQRS(Command Query Responsibility Segregation:コマンド・クエリ責任分離)**の原則に従います:
+1. 現在の状態や表示可能な操作を示すために、必要なら BDR の Query モデルを読む
+2. 状態変更には Command またはアプリケーションの書き込みユースケースを呼ぶ
+3. その書き込み経路で書き込み側の不変条件を検証し、UPDATE、INSERT、DELETE、または別の書き込み手段で結果を永続化する
```php
-// クエリ側(BDRパターン)
+// Query側(BDRパターン): 今の用途に必要なprojection
$order = $this->orderRepo->getOrder($id);
-if ($order->canProcess()) {
- // コマンド側(シンプルな書き込み)
- $this->orderCommandRepo->markAsProcessed($id, new DateTime());
+if ($order->canShowProcessAction()) {
+ // アプリケーションは操作を提示できる。ただし最終判断はCommand側が行う。
+ $this->processOrder->execute($id, new DateTimeImmutable());
}
-// orderCommandRepoはシンプルなSQLを使用:
+// processOrder は必要に応じて明示的な書き込みSQLを使う:
// UPDATE orders SET status = 'processed', processed_at = :timestamp WHERE id = :id
```
-この分離は意図的です:
-- **クエリ**は複雑で、JOINや集約を含むことができる
-- **コマンド**はシンプルで、状態変更に集中すべき
-- **ドメインロジック**はクエリオブジェクトに存在し、データベース書き込みには存在しない
+`canShowProcessAction()` は表示のための読み取り側の派生判断です。`ProcessOrder` は書き込み側の不変条件をあらためて検証しなければなりません。BDR は Command モデルを定義しません。Ray.MediaQuery は DML を実行できますが、それを書き込み手段として選ぶかどうかはアプリケーション側の設計です。
+
+これは**CQRS(Command Query Responsibility Segregation:コマンド・クエリ責任分離)**の区別に従います:
+- **Query モデル**は画面や帳票に合わせてデータを形作り、派生・表示の振る舞いを持つことができる
+- **Command モデル**はドメインの整合性を守り、業務上の行為を実行してよいかを判断する
+- **SQL** は projection に向いている。JOIN、集約、計算、非正規化された結果の形を自然に表せる
### Q: これはCQRSパターンですか?
-**A: はい、特にクエリ(読み取り)側です。** BDRパターンはCQRSのクエリ側の強力な実装です。
+**A: BDRパターンは CQRS の Query 側に適用できます。ただし、より正確には rich read model のパターンです。** Read Repository と Write Repository を別の場所に置く、という話が中心ではありません。重要なのは、関心とモデルを分けることです。
+
+出発点は単純です。読み取りと書き込みでは、最適なモデルが違います。
+
+書き込み側には、ドメインの整合性を守るモデルが必要です。そこでは意図、振る舞い、不変条件、失敗理由を扱います。「この業務上の行為は実行してよいか?」に答えるモデルです。
+
+読み取り側では、画面、帳票、API レスポンスのために、非正規化されたフラットなデータが欲しいことがよくあります。「今表示するにはどんな形が役に立つか?」に答えるモデルです。同じ Repository や Entity モデルで両方を満たそうとするから無理が生じます。
-CQRSは読み取りと書き込みの責務を分離します:
-- **クエリ側(BDRパターン)**:ビジネスロジックを含む豊富なドメインオブジェクトによる複雑な読み取り
-- **コマンド側**:状態を変更するシンプルで焦点を絞った書き込み
+CQRS はしばしば、データベースを分ける、インフラを分ける、Repository の置き場所を分ける、といった物理的なアーキテクチャとして誤解されます。それらは有用な実装上の選択肢になることはありますが、本質ではありません。本質は、Command は業務の意思決定であり、Query は表示のための構造である、という分離です。
-BDRパターンは複雑な部分(クエリ)を以下を組み合わせて処理します:
-- データ取得のためのSQLの力
-- 変換と充実化のためのファクトリー
-- ビジネスロジックのためのドメインオブジェクト
+BDR は薄い DTO に限定されません。読み取りモデルは、派生・表示のロジックである限り、振る舞いを持てます。合計、ラベル、表示可否、読み取り側の優先度分類など、現在の projection に関する問いに答える振る舞いです。状態変更の不変条件は Command 側に置きます。
-一方、コマンド側はシンプルに保たれます:
-- 直接的なUPDATE/INSERT/DELETE文
-- イベントソーシング(必要な場合)
-- 書き込み前のシンプルな検証
+SQL はそもそもこの Query 側の性質を持っています。`SELECT` は JOIN、集約、計算を使って、必要な形へ結果を projection できます。その構造を永続的な正規ドメインモデルであるかのように扱う必要はありません。BDR では、SQL ファイルがその projection を定義し、ファクトリやドメインオブジェクトが PHP の型として表面を与えます。
-この分離により、両側がよりシンプルで保守しやすくなります。
+Query モデルは使い捨てであってもかまいません。画面、帳票、API レスポンスが変われば、別の `SELECT` と小さな読み取りモデルを作ればよい。それは DRY 違反ではなく、CQRS の要点です。関心が違うものには、違うモデルを与えます。
### Q: ファクトリーで外部APIを呼ぶと、リスト取得時に遅くなりませんか?
@@ -686,12 +684,15 @@ final class ProductDomainFactory
```php
final readonly class ProductDomainObject
{
- private ?float $currentPrice = null;
+ public function __construct(
+ private string $id,
+ private PriceProvider $priceProvider,
+ ) {}
public function getCurrentPrice(): float
{
- // 実際に必要な時にのみ取得
- return $this->currentPrice ??= $this->priceService->getPrice($this->id);
+ // 実際に必要な時だけ取得する。キャッシュはreadonlyオブジェクトではなくprovider側で管理する。
+ return $this->priceProvider->getPrice($this->id);
}
}
```
@@ -709,7 +710,11 @@ public function getProduct(string $id): ProductDomainObject;
重要なのは、いつどのようにデータをロードするかについて**意図的であること**です。ファクトリーパターンは、この戦略を完全にコントロールする力を与えます。
-## 関連レシピ
+## BEAR.Sunday 連携
+
+BEAR.Sunday は、アプリケーションの操作を URI で参照できる `ResourceObject` として表す PHP アプリケーションフレームワークです。`#[Embed]` は、それらのリソース間の関係を宣言します。
+
+この境界は BDR と相性が良いです。Repository は **何を**問い合わせるかを宣言したまま、独立したリソースリクエストを **いつ** **どう**実行するかは BEAR.Sunday と BEAR.Async がアプリケーション境界で決めます。Repository interface と SQL file は変わりません。
- [BDR + BEAR.Async: 並列 SQL レシピ](./BEAR_ASYNC_RECIPE-ja.md) — 各リポジトリ呼び出しを `ResourceObject` で包むと、`#[Embed]` がアプリケーション境界でそれらを並列実行する。
diff --git a/BDR_PATTERN.md b/BDR_PATTERN.md
index 17333e8..772d1be 100644
--- a/BDR_PATTERN.md
+++ b/BDR_PATTERN.md
@@ -127,10 +127,10 @@ final readonly class UserDomainObject
private PermissionService $permissionService,
) {}
- // Dynamic business rules through injected service
+ // Read-side business questions through injected service
public function canEdit(Document $document): bool
{
- // Impossible with ORM entities - depends on external service
+ // Difficult with ORM entities - depends on external service
// Test env: FakePermissionService (everyone can edit)
// Production: RealPermissionService (complex permission checks)
return $this->permissionService->canEdit($this, $document);
@@ -141,7 +141,7 @@ final readonly class UserDomainObject
}
```
-In the BDR Pattern, objects are not mere data containers but domain objects containing business logic. They answer questions about the business domain but don't change the database themselves.
+In the BDR Pattern, objects are not mere data containers but read-side domain objects with behavior. They answer questions about the current projection and user experience, but Command models still make the final decision before state changes.
## Implementation Guide
@@ -252,13 +252,13 @@ final readonly class OrderDomainObject
public float $tax, // Calculated by region
public float $shipping, // Calculated shipping
public float $total, // Complete total
- public bool $canFulfill, // Business rule applied
+ public bool $canFulfill, // Read-side rule result
public array $insufficientStockItems, // List of insufficient stock items
// Injected business rule engine - impossible with ORM
private BusinessRuleEngine $ruleEngine,
) {}
- // Domain object behavior
+ // Read-side domain object behavior
public function getDisplayTotal(): string
{
return '$' . number_format($this->total, 2);
@@ -279,15 +279,15 @@ final readonly class OrderDomainObject
return $this->status === 'pending';
}
- public function canProcess(): bool
+ public function canShowProcessAction(): bool
{
return $this->canFulfill && $this->isPending();
}
- // Dynamic business rules through injected service
+ // Read-side priority through injected service
public function getBusinessPriority(): string
{
- // Impossible with ORM entities - depends on external service
+ // Difficult with ORM entities - depends on external service
// Test environment: Relaxed thresholds (e.g., high priority at $100+)
// Production: Strict thresholds (e.g., high priority at $10,000+)
// Peak season: Different thresholds
@@ -317,7 +317,7 @@ Because each layer is **independent**, if each is tested individually, the combi
1. **SQL Query**: Does it return correct data for the input?
2. **Factory**: Does it correctly transform data into domain objects?
-3. **Domain Object**: Does it correctly implement business rules?
+3. **Domain Object**: Does it correctly implement read-side behavior?
If these are individually correct, the combination is necessarily correct. **It's a logical structure.**
@@ -560,54 +560,52 @@ The results achieved are:
In the BDR Pattern, each excels in its own domain while building something greater together.
-## FAQ & Architecture Hints
+## FAQ
### Q: How do I save modified objects back to the database?
-**A: You don't.** Objects in the BDR Pattern are read-only and exist for querying data. When you need to modify data:
+**A: Use an explicit write path, not the BDR read object.** A BDR object is a read-side projection shaped for a screen, report, API response, or use case. It does not save itself.
-1. **Make business decisions** in your application layer
-2. **Issue a Command** - a clear, explicit write operation
-3. **Execute simple write queries** - UPDATE, INSERT, DELETE statements
-
-This follows the **CQRS (Command Query Responsibility Segregation)** principle:
+1. Read a BDR Query model when it helps present the current state or available actions.
+2. Call a Command or application write use case for the state change.
+3. In that write path, validate write-side invariants and persist the result with UPDATE, INSERT, DELETE, or another write mechanism.
```php
-// Query side (BDR Pattern)
+// Query side (BDR Pattern): projection for the current use
$order = $this->orderRepo->getOrder($id);
-if ($order->canProcess()) {
- // Command side (simple write)
- $this->orderCommandRepo->markAsProcessed($id, new DateTime());
+if ($order->canShowProcessAction()) {
+ // The application may offer the action, but the Command owns the final decision.
+ $this->processOrder->execute($id, new DateTimeImmutable());
}
-// orderCommandRepo might use simple SQL:
+// processOrder may use explicit write SQL:
// UPDATE orders SET status = 'processed', processed_at = :timestamp WHERE id = :id
```
-The separation is intentional:
-- **Queries** can be complex, with JOINs and aggregations
-- **Commands** should be simple and focused on changing state
-- **Domain logic** lives in the query objects, not in the database writes
+`canShowProcessAction()` is read-side derivation for presentation. `ProcessOrder` must still enforce the write-side invariant. BDR does not define the Command model; Ray.MediaQuery can execute DML if that is the write mechanism you choose.
+
+This follows the **CQRS (Command Query Responsibility Segregation)** distinction:
+- **Query models** shape data for display or reporting and may contain derivation/presentation behavior
+- **Command models** protect domain consistency and decide whether a business action may happen
+- **SQL** is naturally good at projection: JOINs, aggregations, calculations, and denormalized result shapes
### Q: Is this the CQRS pattern?
-**A: Yes, specifically the Query (read) side.** The BDR Pattern is a powerful implementation of CQRS's query side.
+**A: BDR fits the Query side of CQRS, but more precisely it is a rich read-model pattern.** It is not mainly about placing read repositories and write repositories in different locations. It is about separating concerns and models.
+
+The starting point is simple: reads and writes want different models.
+
+The write side needs a domain model that protects consistency. It carries intent, behavior, invariants, and failure reasons. It answers, "May this business action happen?"
+
+The read side often wants denormalized, flattened data for a screen, report, or API response. It answers, "What shape is useful to display now?" Trying to satisfy both with one Repository or Entity model creates friction.
-CQRS separates read and write responsibilities:
-- **Query side (BDR Pattern)**: Complex reads with rich domain objects containing business logic
-- **Command side**: Simple, focused writes that change state
+CQRS is often mistaken for a physical architecture: separate databases, separate infrastructure, separate repository locations. Those may be useful implementation choices, but they are not the essence. The essence is that Command is business decision, and Query is display structure.
-The BDR Pattern handles the complex part (queries) by combining:
-- SQL's power for data retrieval
-- Factories for transformation and enrichment
-- Domain objects for business logic
+BDR is not limited to a thin DTO. Its read model can expose behavior, as long as that behavior is derivation or presentation logic: totals, labels, visibility, read-side priority, or other answers about the current projection. State-changing invariants stay on the Command side.
-Meanwhile, the command side remains straightforward:
-- Direct UPDATE/INSERT/DELETE statements
-- Event sourcing (if needed)
-- Simple validation before writes
+SQL already has this Query-side character. A `SELECT` can join, aggregate, calculate, and project a result into the exact structure needed without pretending that structure is the canonical domain model. In BDR, the SQL file defines that projection, and the factory/domain object gives it a typed PHP surface.
-This separation makes both sides simpler and more maintainable.
+The Query model may be disposable. If a screen, report, or API response changes, write another `SELECT` and another small read model. That is not a DRY violation; it is the point of CQRS: different concerns get different models.
### Q: Won't calling external APIs in factories slow down list retrievals?
@@ -638,12 +636,15 @@ final class ProductDomainFactory
```php
final readonly class ProductDomainObject
{
- private ?float $currentPrice = null;
+ public function __construct(
+ private string $id,
+ private PriceProvider $priceProvider,
+ ) {}
public function getCurrentPrice(): float
{
- // Only fetch when actually needed
- return $this->currentPrice ??= $this->priceService->getPrice($this->id);
+ // Only fetch when actually needed; cache inside the provider, not this readonly object.
+ return $this->priceProvider->getPrice($this->id);
}
}
```
@@ -661,7 +662,11 @@ public function getProduct(string $id): ProductDomainObject;
The key is **being intentional** about when and how you load data. The factory pattern gives you complete control over this strategy.
-## Related Recipes
+## BEAR.Sunday Integration
+
+BEAR.Sunday is a resource-oriented PHP application framework. Application operations are represented as URI-addressable `ResourceObject`s, and `#[Embed]` declares relationships between those resources.
+
+That boundary is useful for BDR. The Repository still declares **what** SQL to run, while BEAR.Sunday and BEAR.Async decide **when** and **how** independent resource requests run. Repository interfaces and SQL files do not change.
- [BDR + BEAR.Async: Parallel SQL Recipe](./BEAR_ASYNC_RECIPE.md) — wrap each Repository call in a `ResourceObject` and let `#[Embed]` parallelise them at the application boundary.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f62eaa..88671aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,8 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
-- Hands-on tutorial in Japanese covering Ray.MediaQuery 1.1 features end-to-end on SQLite (`docs/tutorial/README.ja.md`)
-- GitHub Pages documentation site (home, feature reference) and a slimmed README landing page
+- English hands-on tutorial covering Ray.MediaQuery 1.1 features end-to-end on SQLite (`docs/tutorial/README.md`), with the Japanese edition preserved at `/tutorial/ja/`
+- Japanese manual covering installation, module setup, SQL conventions, result mapping, factories, parameter handling, pagination, and direct SQL execution (`docs/reference.md`)
+- GitHub Pages documentation site (home, manual) and a slimmed README landing page
### Fixed
- Restore `Ray\MediaQuery\CamelCaseTrait` (deprecated) for 1.x backward compatibility. It was unintentionally removed in 1.1.0, causing a hard fatal (`Trait ... not found`) for entities still importing it ([#93](https://github.com/ray-di/Ray.MediaQuery/issues/93))
diff --git a/README.md b/README.md
index 8590971..723521e 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
+
+
# Ray.MediaQuery
[](https://codecov.io/gh/ray-di/Ray.MediaQuery)
@@ -10,6 +12,7 @@
Define a PHP interface, attach `#[DbQuery]`, write a SQL file, and Ray.MediaQuery provides the implementation through Ray.Di + AOP. Return types and docblocks drive fetching, hydration, pagination, and post-query result objects.
+
```php
use Ray\MediaQuery\Annotation\DbQuery;
@@ -106,12 +109,11 @@ $todos = $todoQuery->list();
## Documentation
-- [Documentation Home](https://ray-di.github.io/Ray.MediaQuery/) — tutorial, reference, and AI-oriented docs.
-- [Hands-on Tutorial (日本語)](https://ray-di.github.io/Ray.MediaQuery/tutorial/) — 13 chapters using SQLite. ([source](./docs/tutorial/README.ja.md))
-- [Feature Reference](https://ray-di.github.io/Ray.MediaQuery/reference/) — result mapping, factories, parameter handling, pagination, and direct SQL execution. ([source](./docs/reference.md))
-- [BDR Pattern Guide](./BDR_PATTERN.md) — architectural approach behind SQL + rich domain objects.
-- [AI-Oriented Reference](https://ray-di.github.io/Ray.MediaQuery/llms-full.txt) — compact reference for coding agents. ([source](./docs/llms-full.txt))
-- [Demo Application](./demo/) — a minimal runnable smoke test of the module wiring; the hands-on tutorial above is the full feature walkthrough.
+Start from the [Documentation Home](https://ray-di.github.io/Ray.MediaQuery/). It is the single entry point for the manual, hands-on tutorial, BDR pattern, FAQ, ecosystem links, and AI-oriented reference.
+
+## Demo Application
+
+See [demo/](./demo/) for a minimal runnable smoke test of the module wiring. The hands-on tutorial in the documentation site is the full feature walkthrough.
## Philosophy
diff --git a/docs/_config.yml b/docs/_config.yml
index 980c02b..d87a030 100644
--- a/docs/_config.yml
+++ b/docs/_config.yml
@@ -1,6 +1,6 @@
title: Ray.MediaQuery
description: Interface-Driven SQL for PHP
-lang: ja
+lang: en
baseurl: /Ray.MediaQuery
markdown: kramdown
highlighter: rouge
diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html
index 84e3230..e5c71e4 100644
--- a/docs/_layouts/default.html
+++ b/docs/_layouts/default.html
@@ -8,7 +8,7 @@
+
+### Q: 変更されたオブジェクトをどうやってDBに書き戻す(保存する)のですか?
+
+**A: BDR の読み取りオブジェクトを保存するのではなく、明示的な書き込み経路を使います。** BDRパターンのオブジェクトは、画面、帳票、API レスポンス、ユースケースに合わせた読み取り側の projection です。オブジェクト自身は保存しません。
+
+1. 現在の状態や表示可能な操作を示すために、必要なら BDR の Query モデルを読む
+2. 状態変更には Command またはアプリケーションの書き込みユースケースを呼ぶ
+3. その書き込み経路で書き込み側の不変条件を検証し、UPDATE、INSERT、DELETE、または別の書き込み手段で結果を永続化する
+
+```php
+// Query側(BDRパターン): 今の用途に必要なprojection
+$order = $this->orderRepo->getOrder($id);
+if ($order->canShowProcessAction()) {
+ // アプリケーションは操作を提示できる。ただし最終判断はCommand側が行う。
+ $this->processOrder->execute($id, new DateTimeImmutable());
+}
+
+// processOrder は必要に応じて明示的な書き込みSQLを使う:
+// UPDATE orders SET status = 'processed', processed_at = :timestamp WHERE id = :id
+```
+
+`canShowProcessAction()` は表示のための読み取り側の派生判断です。`ProcessOrder` は書き込み側の不変条件をあらためて検証しなければなりません。BDR は Command モデルを定義しません。Ray.MediaQuery は DML を実行できますが、それを書き込み手段として選ぶかどうかはアプリケーション側の設計です。
+
+これは**CQRS(Command Query Responsibility Segregation:コマンド・クエリ責任分離)**の区別に従います:
+- **Query モデル**は画面や帳票に合わせてデータを形作り、派生・表示の振る舞いを持つことができる
+- **Command モデル**はドメインの整合性を守り、業務上の行為を実行してよいかを判断する
+- **SQL** は projection に向いている。JOIN、集約、計算、非正規化された結果の形を自然に表せる
+
+### Q: これはCQRSパターンですか?
+
+**A: BDRパターンは CQRS の Query 側に適用できます。ただし、より正確には rich read model のパターンです。** Read Repository と Write Repository を別の場所に置く、という話が中心ではありません。重要なのは、関心とモデルを分けることです。
+
+出発点は単純です。読み取りと書き込みでは、最適なモデルが違います。
+
+書き込み側には、ドメインの整合性を守るモデルが必要です。そこでは意図、振る舞い、不変条件、失敗理由を扱います。「この業務上の行為は実行してよいか?」に答えるモデルです。
+
+読み取り側では、画面、帳票、API レスポンスのために、非正規化されたフラットなデータが欲しいことがよくあります。「今表示するにはどんな形が役に立つか?」に答えるモデルです。同じ Repository や Entity モデルで両方を満たそうとするから無理が生じます。
+
+CQRS はしばしば、データベースを分ける、インフラを分ける、Repository の置き場所を分ける、といった物理的なアーキテクチャとして誤解されます。それらは有用な実装上の選択肢になることはありますが、本質ではありません。本質は、Command は業務の意思決定であり、Query は表示のための構造である、という分離です。
+
+BDR は薄い DTO に限定されません。読み取りモデルは、派生・表示のロジックである限り、振る舞いを持てます。合計、ラベル、表示可否、読み取り側の優先度分類など、現在の projection に関する問いに答える振る舞いです。状態変更の不変条件は Command 側に置きます。
+
+SQL はそもそもこの Query 側の性質を持っています。`SELECT` は JOIN、集約、計算を使って、必要な形へ結果を projection できます。その構造を永続的な正規ドメインモデルであるかのように扱う必要はありません。BDR では、SQL ファイルがその projection を定義し、ファクトリやドメインオブジェクトが PHP の型として表面を与えます。
+
+Query モデルは使い捨てであってもかまいません。画面、帳票、API レスポンスが変われば、別の `SELECT` と小さな読み取りモデルを作ればよい。それは DRY 違反ではなく、CQRS の要点です。関心が違うものには、違うモデルを与えます。
+
+### Q: ファクトリーで外部APIを呼ぶと、リスト取得時に遅くなりませんか?
+
+**A: その通りです、適切な戦略なしでは。** これは本質的にN+1問題の変形です。以下は緩和のための戦略です:
+
+**1. バッチリクエスト**
+```php
+final class ProductDomainFactory
+{
+ private array $priceCache = [];
+
+ public function factory(string $id, string $name): ProductDomainObject
+ {
+ // 価格はファクトリー呼び出し前にバッチで取得済み
+ $price = $this->priceCache[$id] ?? $this->priceService->getPrice($id);
+ return new ProductDomainObject($id, $name, $price);
+ }
+
+ public function warmPriceCache(array $productIds): void
+ {
+ // すべての価格を1回のAPI呼び出しで取得
+ $this->priceCache = $this->priceService->getPrices($productIds);
+ }
+}
+```
+
+**2. 遅延ロード**
+```php
+final readonly class ProductDomainObject
+{
+ public function __construct(
+ private string $id,
+ private PriceProvider $priceProvider,
+ ) {}
+
+ public function getCurrentPrice(): float
+ {
+ // 実際に必要な時だけ取得する。キャッシュはreadonlyオブジェクトではなくprovider側で管理する。
+ return $this->priceProvider->getPrice($this->id);
+ }
+}
+```
+
+**3. 戦略的データロード**
+```php
+// リスト表示:高コストなデータをロードしない
+#[DbQuery('product_list_simple', factory: ProductListFactory::class)]
+public function getProductList(): array;
+
+// 詳細表示:外部データを含むすべてをロード
+#[DbQuery('product_detail', factory: ProductDetailFactory::class)]
+public function getProduct(string $id): ProductDomainObject;
+```
+
+重要なのは、いつどのようにデータをロードするかについて**意図的であること**です。ファクトリーパターンは、この戦略を完全にコントロールする力を与えます。
+
+## BEAR.Sunday 連携
+
+BEAR.Sunday は、アプリケーションの操作を URI で参照できる `ResourceObject` として表す PHP アプリケーションフレームワークです。`#[Embed]` は、それらのリソース間の関係を宣言します。
+
+この境界は BDR と相性が良いです。Repository は **何を**問い合わせるかを宣言したまま、独立したリソースリクエストを **いつ** **どう**実行するかは BEAR.Sunday と BEAR.Async がアプリケーション境界で決めます。Repository interface と SQL file は変わりません。
+
+- [BDR + BEAR.Async: 並列 SQL レシピ](https://github.com/ray-di/Ray.MediaQuery/blob/1.x/BEAR_ASYNC_RECIPE-ja.md) — 各リポジトリ呼び出しを `ResourceObject` で包むと、`#[Embed]` がアプリケーション境界でそれらを並列実行する。
+
+## 参考文献
+
+- [Object-Relational Mapping is the Vietnam of Computer Science](https://blog.codinghorror.com/object-relational-mapping-is-the-vietnam-of-computer-science/) - Jeff Atwood (2006)
diff --git a/docs/bdr-pattern.md b/docs/bdr-pattern.md
new file mode 100644
index 0000000..95e18bd
--- /dev/null
+++ b/docs/bdr-pattern.md
@@ -0,0 +1,683 @@
+---
+layout: default
+title: BDR Pattern Guide
+description: "Practical guide to the Business Domain Repository Pattern: explicit SQL, factories, and immutable domain objects."
+lang: en
+permalink: /bdr-pattern/
+---
+
+# Business Domain Repository Pattern (BDR Pattern) Practical Guide
+
+[日本語 (Japanese)]({{ '/bdr-pattern/ja/' | relative_url }})
+
+## Introduction
+
+Programmers have long grappled with the **boundary** between relational and object-oriented thinking. This problem is known as the "Object-Relational Impedance Mismatch," referring to the fundamental incompatibility between the tabular data of relational databases and the hierarchical object structures of object-oriented programming.
+
+Traditional ORMs attempted to abstract SQL away, **making it invisible**. This abstraction created boundaries that developers constantly felt. We want to leverage the power of databases while maintaining object-oriented principles. How to reconcile these two desires has always been a challenge.
+
+**The BDR Pattern dissolves this boundary.**
+
+**In the BDR Pattern, SQL and OOP shake hands**. Each performs what it does best while working in harmony.
+
+The friction caused by boundaries is eliminated. There's no longer a need for forced abstractions or for one paradigm to pretend the other doesn't exist.
+
+## Executive Summary
+
+The BDR Pattern presents a new paradigm where **Object-Oriented Programming and SQL work in harmony**. It achieves "OOP autonomy with SQL foundation" and enables:
+
+**Core Value Propositions:**
+- **SQL remains SQL**: Complex queries, JOINs, window functions - all at maximum performance
+- **Objects remain objects**: Autonomous domain models with rich behavior
+- **Leveraging both strengths**: Achieving both object-oriented design and SQL performance
+- **Clear testing**: Each component can be tested independently
+
+## Why This Matters
+
+### Common Scenarios in Development
+
+Complex business logic scattered across controllers. One change requires modifications to multiple methods, and testing requires numerous mock objects.
+
+When using ORMs, we encounter characteristics such as:
+- Need to handle N+1 query problems
+- Constraints in expressing complex JOINs
+- Difficulty predicting generated SQL
+- Creative workarounds needed for performance tuning
+
+**The BDR Pattern proposes a different approach.**
+
+It leverages the strengths of both SQL and OOP, allowing each to shine in their respective domains.
+
+### The BDR Pattern Approach
+
+```php
+public function showOrderDetails(string $id): Response
+{
+ $order = $this->orderRepo->getOrder($id);
+ return $this->render('order.html.twig', ['order' => $order]);
+}
+
+// Business logic in factories
+// SQL in optimized query files
+// Tests in independent layers
+```
+
+Simple structure improves maintainability and readability.
+
+## The Problem and Solution
+
+### Traditional Approach Problems
+
+```php
+class OrderController
+{
+ public function show(string $id): Response
+ {
+ $order = $this->orderRepo->findById($id); // Simple data
+
+ // Business logic scattered in controller - testing nightmare!
+ $items = $this->inventoryService->checkStock($order->items);
+ $tax = $this->taxCalculator->calculate($items, $order->region);
+ $shipping = $this->shippingService->calculate($items, $order->region);
+ $canFulfill = $this->validateOrder($items, $order->status);
+
+ // Testing this controller requires mocking 6+ dependencies!
+
+ return $this->render('order.html.twig', compact('order', 'tax', 'shipping', 'canFulfill'));
+ }
+}
+```
+
+### BDR Pattern Solution
+
+```php
+class OrderController
+{
+ public function show(string $id): Response
+ {
+ // Repository returns complete domain object
+ $order = $this->orderRepo->getOrder($id);
+
+ // Controller only renders - no business logic
+ return $this->render('order.html.twig', ['order' => $order]);
+ }
+}
+```
+
+## Object Autonomy
+
+The BDR Pattern achieves something important: **true object autonomy with SQL as the foundation**.
+
+Balancing object autonomy and SQL efficiency was traditionally considered difficult. The BDR Pattern achieves this balance. Domain objects are self-contained with their own behavior and data, while their creation is efficiently powered by SQL queries.
+
+### Read-Only, Immutable Domain Objects
+
+**Critically, domain objects in the BDR Pattern are read-only and immutable.** They represent a snapshot of the database at a specific point in time. These objects:
+
+- **Have no `save()` methods** - They don't persist themselves
+- **Have no setters** - State cannot be modified after creation
+- **Are query results** - They represent the "read" side of your architecture
+
+This immutability is intentional and brings important benefits:
+- **Thread-safe by default** - Safe to share across concurrent operations
+- **Predictable behavior** - State never changes unexpectedly
+- **Clear intent** - Separation between queries (reading data) and commands (changing data)
+
+```php
+// Leveraging the power of DI in domain objects
+final readonly class UserDomainObject
+{
+ public function __construct(
+ public string $id,
+ public string $name,
+ public string $role,
+ // Service injected from factory
+ private PermissionService $permissionService,
+ ) {}
+
+ // Read-side business questions through injected service
+ public function canEdit(Document $document): bool
+ {
+ // Difficult with ORM entities - depends on external service
+ // Test env: FakePermissionService (everyone can edit)
+ // Production: RealPermissionService (complex permission checks)
+ return $this->permissionService->canEdit($this, $document);
+ }
+
+ // Note: No save(), update(), or setter methods
+ // This object is a read-only snapshot
+}
+```
+
+In the BDR Pattern, objects are not mere data containers but read-side domain objects with behavior. They answer questions about the current projection and user experience, but Command models still make the final decision before state changes.
+
+## Implementation Guide
+
+### 1. Repository Interface Definition
+
+```php
+interface OrderRepositoryInterface
+{
+ #[DbQuery('order_detail', factory: OrderDomainFactory::class)]
+ public function getOrder(string $id): OrderDomainObject;
+
+ #[DbQuery('active_orders', factory: OrderDomainFactory::class)]
+ /** @return array */
+ public function getActiveOrders(): array;
+}
+```
+
+### 2. SQL Query (order_detail.sql)
+
+```sql
+SELECT
+ o.id,
+ o.customer_id,
+ o.region,
+ o.status,
+ o.created_at,
+ JSON_ARRAYAGG(
+ JSON_OBJECT(
+ 'product_id', oi.product_id,
+ 'name', p.name,
+ 'quantity', oi.quantity,
+ 'price', oi.price,
+ 'current_stock', p.stock
+ )
+ ) as items
+FROM orders o
+JOIN order_items oi ON o.id = oi.order_id
+JOIN products p ON oi.product_id = p.product_id
+WHERE o.id = :id
+GROUP BY o.id
+```
+
+### 3. Domain Factory Implementation
+
+```php
+final class OrderDomainFactory
+{
+ public function __construct(
+ private TaxCalculator $taxCalculator,
+ private ShippingService $shippingService,
+ private InventoryService $inventoryService,
+ private BusinessRuleEngine $ruleEngine,
+ ) {}
+
+ public function factory(
+ string $id,
+ string $customer_id,
+ string $region,
+ string $status,
+ string $items_json
+ ): OrderDomainObject {
+ $items = json_decode($items_json, true);
+
+ // Centralize business logic in factory
+ $validatedItems = $this->inventoryService->validateStock($items);
+ $subtotal = array_sum(array_map(fn($item) => $item['price'] * $item['quantity'], $items));
+ $tax = $this->taxCalculator->calculate($validatedItems, $region);
+ $shipping = $this->shippingService->calculate($validatedItems, $region);
+
+ return new OrderDomainObject(
+ id: $id,
+ customerId: $customer_id,
+ region: $region,
+ status: $status,
+ items: $validatedItems,
+ subtotal: $subtotal,
+ tax: $tax,
+ shipping: $shipping,
+ total: $subtotal + $tax + $shipping,
+ canFulfill: count($validatedItems) === count($items) && $status === 'pending',
+ insufficientStockItems: $this->getInsufficientStockItems($items, $validatedItems),
+ ruleEngine: $this->ruleEngine,
+ );
+ }
+
+ private function getInsufficientStockItems(array $original, array $validated): array
+ {
+ // Business logic to identify items with insufficient stock
+ return array_filter($original, fn($item) =>
+ !in_array($item['product_id'], array_column($validated, 'product_id'))
+ );
+ }
+}
+```
+
+### 4. Rich Domain Object
+
+```php
+final readonly class OrderDomainObject
+{
+ public function __construct(
+ public string $id,
+ public string $customerId,
+ public string $region,
+ public string $status,
+ public array $items, // Stock-validated items
+ public float $subtotal,
+ public float $tax, // Calculated by region
+ public float $shipping, // Calculated shipping
+ public float $total, // Complete total
+ public bool $canFulfill, // Read-side rule result
+ public array $insufficientStockItems, // List of insufficient stock items
+ // Injected business rule engine - impossible with ORM
+ private BusinessRuleEngine $ruleEngine,
+ ) {}
+
+ // Read-side domain object behavior
+ public function getDisplayTotal(): string
+ {
+ return '$' . number_format($this->total, 2);
+ }
+
+ public function hasInsufficientStock(): bool
+ {
+ return count($this->insufficientStockItems) > 0;
+ }
+
+ public function getTaxRate(): float
+ {
+ return $this->subtotal > 0 ? ($this->tax / $this->subtotal) * 100 : 0;
+ }
+
+ public function isPending(): bool
+ {
+ return $this->status === 'pending';
+ }
+
+ public function canShowProcessAction(): bool
+ {
+ return $this->canFulfill && $this->isPending();
+ }
+
+ // Read-side priority through injected service
+ public function getBusinessPriority(): string
+ {
+ // Difficult with ORM entities - depends on external service
+ // Test environment: Relaxed thresholds (e.g., high priority at $100+)
+ // Production: Strict thresholds (e.g., high priority at $10,000+)
+ // Peak season: Different thresholds
+ // VIP customers: Special rules apply
+ return $this->ruleEngine->calculatePriority($this);
+ }
+}
+```
+
+## Three-Layer Testing Strategy: Simple and Reliable Testing
+
+One of the important advantages of the BDR Pattern is that **testing becomes simple and reliable**.
+
+### Common Testing Challenges
+
+Common challenges in testing include:
+- Integration tests taking a long time to run
+- Test instability due to database state dependencies
+- Complex mock setups
+- Intermittently failing tests
+
+**The BDR Pattern provides a better way.**
+
+### Why Testing Becomes Simple
+
+Because each layer is **independent**, if each is tested individually, the combination naturally works:
+
+1. **SQL Query**: Does it return correct data for the input?
+2. **Factory**: Does it correctly transform data into domain objects?
+3. **Domain Object**: Does it correctly implement read-side behavior?
+
+If these are individually correct, the combination is necessarily correct. **It's a logical structure.**
+
+### 1. SQL Layer Testing
+
+```php
+class UserQueryTest extends DatabaseTestCase
+{
+ public function testUserByIdQuery(): void
+ {
+ // Prepare test data
+ $this->insertUser('user-1', 'Alice', 'alice@example.com', 'editor');
+
+ // Execute query
+ $result = $this->executeQuery('user_by_id.sql', ['id' => 'user-1']);
+
+ // Verify results
+ $this->assertEquals('Alice', $result[0]['name']);
+ $this->assertEquals('editor', $result[0]['role']);
+ }
+}
+```
+
+### 2. Factory Layer Testing
+
+```php
+class UserDomainFactoryTest extends TestCase
+{
+ public function testCreatesUserWithInjectedService(): void
+ {
+ // Inject fake service
+ $permissionService = new FakePermissionService();
+ $factory = new UserDomainFactory($permissionService);
+
+ // Test factory
+ $user = $factory->factory('user-1', 'Alice', 'alice@example.com', 'editor');
+
+ // Verify object is created correctly
+ $this->assertEquals('Alice', $user->name);
+ $this->assertEquals('editor', $user->role);
+
+ // Confirm injected service works
+ $document = new Document('doc-1', 'user-1');
+ $this->assertTrue($user->canEdit($document));
+ }
+}
+```
+
+### 3. Domain Object Testing
+
+```php
+class UserDomainObjectTest extends TestCase
+{
+ public function testCanEditWithDifferentPermissionServices(): void
+ {
+ $document = new Document('doc-1', 'user-2');
+
+ // Restrictive service
+ $strictService = new StrictPermissionService();
+ $user1 = new UserDomainObject('user-1', 'Alice', 'alice@example.com', 'editor', $strictService);
+ $this->assertFalse($user1->canEdit($document)); // Cannot edit others' documents
+
+ // Permissive service
+ $relaxedService = new RelaxedPermissionService();
+ $user2 = new UserDomainObject('user-1', 'Alice', 'alice@example.com', 'editor', $relaxedService);
+ $this->assertTrue($user2->canEdit($document)); // Editors can edit all documents
+ }
+}
+```
+
+Because each layer is tested independently, integration issues are extremely rare. This eliminates the need for complex and fragile integration tests.
+
+## Practical Patterns
+
+### Polymorphic Domain Objects
+
+```php
+final class UserDomainFactory
+{
+ public function factory(string $id, string $email, string $type): UserInterface
+ {
+ return match ($type) {
+ 'free' => new FreeUser($id, $email, maxStorage: 100),
+ 'premium' => new PremiumUser($id, $email, maxStorage: 1000),
+ };
+ }
+}
+```
+
+### External API Integration
+
+```php
+final class ProductDomainFactory
+{
+ public function __construct(
+ private PriceService $priceService, // External API
+ ) {}
+
+ public function factory(string $id, string $name): ProductDomainObject
+ {
+ return new ProductDomainObject(
+ id: $id,
+ name: $name,
+ currentPrice: $this->priceService->getCurrentPrice($id),
+ );
+ }
+}
+```
+
+### Caching Strategy
+
+```php
+final class UserDomainFactory
+{
+ public function __construct(
+ private CacheInterface $cache,
+ private PermissionService $permissionService,
+ ) {}
+
+ public function factory(string $id, string $name, string $role): UserDomainObject
+ {
+ // Cache expensive permission lookups
+ $permissions = $this->cache->remember(
+ "permissions_{$role}",
+ 3600,
+ fn() => $this->permissionService->getPermissions($role)
+ );
+
+ return new UserDomainObject($id, $name, $role, $permissions);
+ }
+}
+```
+
+## Migration from Existing Projects
+
+### Step 1: Identify Business Logic
+
+```php
+// Before: Logic scattered in controller
+class ProductController
+{
+ public function show($id)
+ {
+ $product = $this->repo->find($id);
+
+ // Identify this business logic
+ $product->finalPrice = $this->calculatePrice($product);
+ $product->inStock = $this->inventory->check($product->id);
+ $product->reviews = $this->reviewService->get($product->id);
+
+ return view('product', compact('product'));
+ }
+}
+```
+
+### Step 2: Create Domain Factory
+
+```php
+// After: Move logic to factory
+final class ProductDomainFactory
+{
+ public function factory($id, $basePrice, $categoryId): ProductDomainObject
+ {
+ return new ProductDomainObject(
+ id: $id,
+ finalPrice: $this->calculatePrice($basePrice, $categoryId),
+ inStock: $this->inventory->check($id),
+ reviews: $this->reviewService->get($id),
+ );
+ }
+}
+```
+
+### Step 3: Gradual Migration
+
+1. **Start with new features** - Implement new features with BDR Pattern
+2. **Prioritize high-traffic endpoints** - Greater performance improvement impact
+3. **Leverage existing test coverage** - Migrate while utilizing existing tests
+4. **Share knowledge within the team** - Share the benefits of the factory pattern
+
+## Adapting to the AI Era: Achieving Transparency
+
+Another advantage of the BDR Pattern is creating a **codebase transparent to AI tools**.
+
+Complex abstraction layers of traditional ORMs were black boxes to AI:
+- Unclear what SQL would be executed
+- Difficult to trace where business logic exists
+- Implicit dependencies difficult to understand
+
+In the BDR Pattern, everything is explicit:
+- **What data is accessed**: Visible in SQL files
+- **How it's transformed**: Clear in factory methods
+- **What services are used**: Explicit in constructors
+- **Business logic flow**: Traceable from query → factory → domain object
+
+```sql
+-- order_detail.sql - AI can read and understand this
+SELECT
+ o.id,
+ o.region,
+ JSON_ARRAYAGG(
+ JSON_OBJECT(
+ 'product_id', oi.product_id,
+ 'quantity', oi.quantity,
+ 'price', oi.price
+ )
+ ) as items
+FROM orders o
+JOIN order_items oi ON o.id = oi.order_id
+WHERE o.id = :id
+```
+
+```php
+// Factory - AI fully understands dependencies and logic
+public function __construct(
+ private TaxCalculator $taxCalculator, // Explicit dependency
+ private ShippingService $shippingService, // Explicit dependency
+) {}
+```
+
+This transparency enables AI assistants to deeply understand your codebase and provide more accurate suggestions and automation.
+
+## Summary
+
+The BDR Pattern presents **one form of domain collaboration**. It not only bridges different paradigms but also **dissolves boundaries between different media**.
+
+SQL (declarative, set-based) and OOP (imperative, object-based). How to combine these technologies with different characteristics has been a long-standing challenge.
+
+**The BDR Pattern provides one approach to this challenge.**
+
+The boundaries created by traditional ORMs abstracting SQL. The BDR Pattern dissolves these and creates new harmony.
+
+The results achieved are:
+- **Controllers become simple** - Focus on presentation
+- **Business logic in the right place** - Placed in factories
+- **Testing is clear and independent** - Each layer ensures quality independently
+- **No performance compromise** - Maximize SQL performance
+
+**SQL and OOP work in harmony.**
+
+In the BDR Pattern, each excels in its own domain while building something greater together.
+
+
FAQ
+
+### Q: How do I save modified objects back to the database?
+
+**A: Use an explicit write path, not the BDR read object.** A BDR object is a read-side projection shaped for a screen, report, API response, or use case. It does not save itself.
+
+1. Read a BDR Query model when it helps present the current state or available actions.
+2. Call a Command or application write use case for the state change.
+3. In that write path, validate write-side invariants and persist the result with UPDATE, INSERT, DELETE, or another write mechanism.
+
+```php
+// Query side (BDR Pattern): projection for the current use
+$order = $this->orderRepo->getOrder($id);
+if ($order->canShowProcessAction()) {
+ // The application may offer the action, but the Command owns the final decision.
+ $this->processOrder->execute($id, new DateTimeImmutable());
+}
+
+// processOrder may use explicit write SQL:
+// UPDATE orders SET status = 'processed', processed_at = :timestamp WHERE id = :id
+```
+
+`canShowProcessAction()` is read-side derivation for presentation. `ProcessOrder` must still enforce the write-side invariant. BDR does not define the Command model; Ray.MediaQuery can execute DML if that is the write mechanism you choose.
+
+This follows the **CQRS (Command Query Responsibility Segregation)** distinction:
+- **Query models** shape data for display or reporting and may contain derivation/presentation behavior
+- **Command models** protect domain consistency and decide whether a business action may happen
+- **SQL** is naturally good at projection: JOINs, aggregations, calculations, and denormalized result shapes
+
+### Q: Is this the CQRS pattern?
+
+**A: BDR fits the Query side of CQRS, but more precisely it is a rich read-model pattern.** It is not mainly about placing read repositories and write repositories in different locations. It is about separating concerns and models.
+
+The starting point is simple: reads and writes want different models.
+
+The write side needs a domain model that protects consistency. It carries intent, behavior, invariants, and failure reasons. It answers, "May this business action happen?"
+
+The read side often wants denormalized, flattened data for a screen, report, or API response. It answers, "What shape is useful to display now?" Trying to satisfy both with one Repository or Entity model creates friction.
+
+CQRS is often mistaken for a physical architecture: separate databases, separate infrastructure, separate repository locations. Those may be useful implementation choices, but they are not the essence. The essence is that Command is business decision, and Query is display structure.
+
+BDR is not limited to a thin DTO. Its read model can expose behavior, as long as that behavior is derivation or presentation logic: totals, labels, visibility, read-side priority, or other answers about the current projection. State-changing invariants stay on the Command side.
+
+SQL already has this Query-side character. A `SELECT` can join, aggregate, calculate, and project a result into the exact structure needed without pretending that structure is the canonical domain model. In BDR, the SQL file defines that projection, and the factory/domain object gives it a typed PHP surface.
+
+The Query model may be disposable. If a screen, report, or API response changes, write another `SELECT` and another small read model. That is not a DRY violation; it is the point of CQRS: different concerns get different models.
+
+### Q: Won't calling external APIs in factories slow down list retrievals?
+
+**A: Yes, without proper strategy.** This is essentially an N+1 problem variant. Here are strategies to mitigate:
+
+**1. Batch Requests**
+```php
+final class ProductDomainFactory
+{
+ private array $priceCache = [];
+
+ public function factory(string $id, string $name): ProductDomainObject
+ {
+ // Prices fetched in batch before factory calls
+ $price = $this->priceCache[$id] ?? $this->priceService->getPrice($id);
+ return new ProductDomainObject($id, $name, $price);
+ }
+
+ public function warmPriceCache(array $productIds): void
+ {
+ // Fetch all prices in one API call
+ $this->priceCache = $this->priceService->getPrices($productIds);
+ }
+}
+```
+
+**2. Lazy Loading**
+```php
+final readonly class ProductDomainObject
+{
+ public function __construct(
+ private string $id,
+ private PriceProvider $priceProvider,
+ ) {}
+
+ public function getCurrentPrice(): float
+ {
+ // Only fetch when actually needed; cache inside the provider, not this readonly object.
+ return $this->priceProvider->getPrice($this->id);
+ }
+}
+```
+
+**3. Strategic Data Loading**
+```php
+// List view: Don't load expensive data
+#[DbQuery('product_list_simple', factory: ProductListFactory::class)]
+public function getProductList(): array;
+
+// Detail view: Load everything including external data
+#[DbQuery('product_detail', factory: ProductDetailFactory::class)]
+public function getProduct(string $id): ProductDomainObject;
+```
+
+The key is **being intentional** about when and how you load data. The factory pattern gives you complete control over this strategy.
+
+## BEAR.Sunday Integration
+
+BEAR.Sunday is a resource-oriented PHP application framework. Application operations are represented as URI-addressable `ResourceObject`s, and `#[Embed]` declares relationships between those resources.
+
+That boundary is useful for BDR. The Repository still declares **what** SQL to run, while BEAR.Sunday and BEAR.Async decide **when** and **how** independent resource requests run. Repository interfaces and SQL files do not change.
+
+- [BDR + BEAR.Async: Parallel SQL Recipe](https://github.com/ray-di/Ray.MediaQuery/blob/1.x/BEAR_ASYNC_RECIPE.md) — wrap each Repository call in a `ResourceObject` and let `#[Embed]` parallelise them at the application boundary.
+
+## References
+
+- [Object-Relational Mapping is the Vietnam of Computer Science](https://blog.codinghorror.com/object-relational-mapping-is-the-vietnam-of-computer-science/) - Jeff Atwood (2006)
diff --git a/docs/index.html b/docs/index.html
index 3ac1831..f2e3933 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -1,54 +1,154 @@
---
layout: default
title: Ray.MediaQuery Documentation
-description: Documentation links for Ray.MediaQuery, including the hands-on tutorial and AI-oriented reference.
-lang: ja
+description: Documentation links for Ray.MediaQuery, including the English tutorial, Japanese manual, and AI-oriented reference.
+lang: en
permalink: /
+home: true
---
+
+
+
+
-
Ray.MediaQuery Documentation
-
Interface-Driven SQL for PHP
-
- SQL は SQL のまま、PHP は PHP のまま。Ray.MediaQuery のチュートリアルと AI 向けリファレンスへの入口です。
- Keep SQL explicit and PHP typed. Start with the tutorial, or open the compact AI-oriented reference.
-
- 現在の詳しいチュートリアルは日本語版です。README と AI 向けリファレンスは英語で利用できます。
- The detailed tutorial is currently available in Japanese. The README and AI-oriented reference are available in English.
-
-
+
diff --git a/docs/ja/index.md b/docs/ja/index.md
index 91ff9a5..14e0baf 100644
--- a/docs/ja/index.md
+++ b/docs/ja/index.md
@@ -1,3 +1,5 @@
# Ray.MediaQuery Documentation
-1. [MediaQuery](./MediaQuery.md)
+1. [マニュアル](../reference/)
+2. [ハンズオンチュートリアル](../tutorial/ja/)
+3. [旧 MediaQuery メモ](./MediaQuery.md)
diff --git a/docs/reference.md b/docs/reference.md
index f93dbe6..1b06970 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -1,27 +1,127 @@
---
layout: default
-title: Ray.MediaQuery Feature Reference
-description: Detailed feature reference for Ray.MediaQuery result mapping, factories, parameter handling, pagination, and direct SQL execution.
-lang: en
+title: Ray.MediaQuery マニュアル
+description: Ray.MediaQuery のインストール、モジュール設定、SQL ファイル規約、戻り値マッピング、ファクトリ、パラメータ処理、ページネーション、直接 SQL 実行のユーザーマニュアル。
+lang: ja
permalink: /reference/
---
-# Ray.MediaQuery Feature Reference
+# Ray.MediaQuery マニュアル
-Detailed reference for Ray.MediaQuery features. For a guided introduction, start with the [hands-on tutorial](https://ray-di.github.io/Ray.MediaQuery/tutorial/).
+Ray.MediaQuery を使うための完全ガイドです。パッケージのインストール、モジュール配線、SQL ファイル規約を押さえた上で、各機能の詳細を確認できます。順を追って体験したい場合は、[ハンズオンチュートリアル](https://ray-di.github.io/Ray.MediaQuery/tutorial/) から始めてください。
-## Features
+このマニュアルで扱う内容:
-### Result Mapping & Entity Hydration
+- [インストール](#インストール)
+- [セットアップ](#セットアップ) — DI モジュールの配線と query instance の取得
+- [SQL ファイル](#sql-ファイル) — 配置場所、命名、placeholder 規約
+- [設定](#設定) — 接続、module の選択、高度な hook
+- [機能](#機能) — 結果マッピング、factory、parameter、pagination、直接 SQL 実行
-Ray.MediaQuery automatically hydrates query results based on your return type declarations:
+## インストール
+
+```bash
+composer require ray/media-query
+```
+
+要件:
+
+- **PHP 8.2+**。
+- 利用するデータベースの PDO driver (`pdo_sqlite`, `pdo_mysql` など)。
+- Ray.MediaQuery は [Ray.Di](https://ray-di.github.io/) (dependency injection) と [Ray.AuraSqlModule](https://github.com/ray-di/Ray.AuraSqlModule) (PDO connection) を基盤にします。どちらも依存として install されます。
+
+## セットアップ
+
+Ray.MediaQuery は query interface の実装を runtime に生成します。そのため setup で必要なのは 2 つです。MediaQuery module を install して interface と SQL directory を対応づけ、`AuraSqlModule` を install して database connection を供給します。その後、injector から interface を取得します。
+
+### Auto-discovery: `MediaQuerySqlModule` (推奨)
+
+Query interface の directory と SQL file の directory を module に渡します。`interfaceDir` 配下にある interface は自動で bind されます。
+
+```php
+use Ray\AuraSqlModule\AuraSqlModule;
+use Ray\Di\AbstractModule;
+use Ray\Di\Injector;
+use Ray\MediaQuery\MediaQuerySqlModule;
+
+final class AppModule extends AbstractModule
+{
+ protected function configure(): void
+ {
+ $this->install(new MediaQuerySqlModule(
+ interfaceDir: __DIR__ . '/Query', // #[DbQuery] interface の directory
+ sqlDir: __DIR__ . '/sql', // .sql file の directory
+ ));
+ $this->install(new AuraSqlModule('sqlite::memory:')); // PDO connection (DSN)
+ }
+}
+
+$injector = new Injector(new AppModule());
+$userQuery = $injector->getInstance(UserQueryInterface::class); // 生成された実装
+$user = $userQuery->item('user-123');
+```
+
+### 明示リスト: `MediaQueryModule`
+
+Directory scan ではなく interface を明示したい場合は、`Queries` list を作り、SQL directory 用の `DbQueryConfig` と一緒に渡します。
+
+```php
+use Ray\AuraSqlModule\AuraSqlModule;
+use Ray\MediaQuery\DbQueryConfig;
+use Ray\MediaQuery\MediaQueryModule;
+use Ray\MediaQuery\Queries;
+
+protected function configure(): void
+{
+ $queries = Queries::fromClasses([
+ UserQueryInterface::class,
+ OrderQueryInterface::class,
+ ]);
+ $this->install(new MediaQueryModule($queries, [new DbQueryConfig(__DIR__ . '/sql')]));
+ $this->install(new AuraSqlModule('sqlite::memory:'));
+}
+```
+
+どちらの module も同じ結果を作ります。`MediaQuerySqlModule` は directory-based の shortcut、`MediaQueryModule` は明示的な構成です。Directory から `Queries` list を作りたい場合は `Queries::fromDir($dir)` も使えます。
+
+## SQL ファイル
+
+- Query ごとに `{queryId}.sql` という名前で `sqlDir` に保存します。`#[DbQuery('user_item')]` は `sqlDir/user_item.sql` に対応します。
+- Placeholder は **named** で、同じ名前の method argument に bind されます。たとえば `:userId` は `string $userId` に対応します。名前で bind されるため、引数順は問いません。
+- 1 ファイルに複数 statement を書けます。Statement は `;` で区切られ、順に実行されます。結果は**最後の statement** を反映します。詳細は [結果マッピング](#結果マッピングと-entity-hydration) を参照してください。
+
+```sql
+-- sql/user_item.sql
+SELECT id, name FROM users WHERE id = :id;
+```
+
+```php
+interface UserQueryInterface
+{
+ #[DbQuery('user_item', type: 'row')]
+ public function item(string $id): User|null;
+}
+```
+
+## 設定
+
+- **Database connection** — `AuraSqlModule` が供給します。`'mysql:host=localhost;dbname=app'`, `'pgsql:host=...;dbname=...'`, `'sqlite::memory:'` など任意の PDO DSN を渡せます。Connection pooling、primary/replica、connection option は [Ray.AuraSqlModule](https://github.com/ray-di/Ray.AuraSqlModule) を参照してください。
+- **Module choice** — `MediaQuerySqlModule` (directory scan) と `MediaQueryModule` (明示的な `Queries` + `DbQueryConfig`) を選べます。詳細は [セットアップ](#セットアップ) を参照してください。
+- **Advanced hooks** — `MediaQuerySqlTemplateModule` / `SqlTemplate` で SQL execution template を差し替えられます。`MediaQueryLoggerInterface` は query logging の拡張点です。通常の application は上記 2 つの module で足ります。
+
+## 機能
+
+### 結果マッピングと Entity Hydration
+
+Ray.MediaQuery は、メソッドの戻り値型宣言に基づいてクエリ結果を自動的に hydrate します。
+
+**単一 Entity:**
-**Single Entity:**
```php
interface UserRepository
{
#[DbQuery('user_find')]
- public function find(string $id): User|null; // Returns User or null
+ public function find(string $id): User|null; // User または null を返す
}
class User
@@ -34,17 +134,19 @@ class User
}
```
-**Entity Array:**
+**Entity 配列:**
+
```php
interface UserRepository
{
#[DbQuery('user_list')]
/** @return array */
- public function findAll(): array; // Returns User[]
+ public function findAll(): array; // User[] を返す
}
```
-**Raw Array (single row):**
+**生配列 (単一行):**
+
```php
interface UserRepository
{
@@ -53,7 +155,8 @@ interface UserRepository
}
```
-**Raw Array (multiple rows):**
+**生配列 (複数行):**
+
```php
interface UserRepository
{
@@ -62,12 +165,12 @@ interface UserRepository
}
```
-**DML Result types — `AffectedRows` / `InsertedRow`:**
+**DML 結果型: `AffectedRows` / `InsertedRow`**
-Declare a result type that implements `PostQueryInterface` to receive post-execution information. The framework ships two:
+`PostQueryInterface` を実装する結果型を戻り値として宣言すると、実行後の情報を受け取れます。フレームワークには以下の 2 つが同梱されています。
-- `AffectedRows` — row count for `UPDATE` / `DELETE`.
-- `InsertedRow` — the resolved parameter values plus the auto-increment id for `INSERT`.
+- `AffectedRows` — `UPDATE` / `DELETE` の影響行数。
+- `InsertedRow` — 解決済みパラメータ値と、`INSERT` 後の auto-increment id。
```php
use Ray\MediaQuery\Result\AffectedRows;
@@ -86,23 +189,23 @@ interface TodoRepository
}
$inserted = $todoRepo->add('Write docs');
-$inserted->values; // array — parameters as bound to the driver (UUIDs, timestamps, DateTime→string, ToScalar reductions all resolved)
-$inserted->id; // string|null — auto-increment id, null if the driver reports none
+$inserted->values; // array — ドライバに bind された実際の値 (UUID、timestamp、DateTime→string、ToScalar の縮約後)
+$inserted->id; // string|null — auto-increment id。ドライバが返さない場合は null
$deleted = $todoRepo->delete('1');
-$deleted->count; // int — rows deleted
-$deleted->isAffected(); // bool — true when count > 0
+$deleted->count; // int — 削除行数
+$deleted->isAffected(); // bool — count > 0 なら true
```
-`InsertedRow::$values` is the result of Ray.MediaQuery's parameter resolution — injected defaults (UUIDs, timestamps), `DateTime` converted to SQL strings, and `ToScalarInterface` value objects reduced to scalars. Those are the values that actually went to the database and are not otherwise observable by the caller.
+`InsertedRow::$values` は Ray.MediaQuery のパラメータ解決結果です。注入されたデフォルト値 (UUID、timestamp)、SQL 文字列へ変換された `DateTime`、スカラーに縮約された `ToScalarInterface` 値オブジェクトなど、実際にデータベースへ渡された値が入ります。呼び出し側からは通常観測できない値です。
-The return type **is** the intent declaration — the framework does not sniff the SQL. Pick `InsertedRow` when you need the id or the resolved values, `AffectedRows` otherwise. Existing `void` return types keep working unchanged.
+戻り値型そのものが意図の宣言です。フレームワークは SQL を推測して判定しません。id や解決済み値が必要なら `InsertedRow`、影響行数だけでよいなら `AffectedRows` を選びます。既存の `void` 戻り値型はそのまま動作します。
-When a SQL file contains multiple statements (separated by `;`), the result reflects the **last executed statement only**.
+SQL ファイルに複数 statement (`;` 区切り) が含まれる場合、結果は**最後に実行された statement** だけを反映します。
-**Custom result types:**
+**カスタム結果型:**
-Any class implementing `Ray\MediaQuery\Result\PostQueryInterface` can be declared as a return type. The interface defines a single static factory that builds the result from a `PostQueryContext` carrying the executed statement, the connection, and the resolved parameter values:
+`Ray\MediaQuery\Result\PostQueryInterface` を実装する任意のクラスを戻り値型として宣言できます。このインターフェイスは、実行済み statement、接続、解決済みパラメータ値を持つ `PostQueryContext` から結果を組み立てる static factory を 1 つ定義します。
```php
use Ray\MediaQuery\Result\PostQueryContext;
@@ -122,11 +225,11 @@ final class RowCountWithQuery implements PostQueryInterface
}
```
-Declare it on any `#[DbQuery]` method and the interceptor dispatches to the class's own factory.
+`#[DbQuery]` メソッドの戻り値にこのクラスを宣言すると、インターセプタはそのクラス自身の factory に処理を委譲します。
-**SELECT collections — typed row wrappers:**
+**SELECT コレクション: 型付き row ラッパー**
-`PostQueryInterface` also covers SELECT. The framework pre-hydrates the result set into `PostQueryContext::$rows` (entity instances when a `factory:` attribute or `@return Wrapper` docblock resolves an entity, associative arrays otherwise). Your wrapper class composes those rows — it never touches raw `PDOStatement` or DI:
+`PostQueryInterface` は SELECT にも対応します。フレームワークは結果セットを `PostQueryContext::$rows` に事前 hydrate します。`factory:` 属性や `@return Wrapper` docblock から Entity が解決できる場合は Entity インスタンス、それ以外は連想配列です。ラッパークラスはそれらの row を合成するだけで、raw `PDOStatement` や DI を直接扱いません。
```php
use ArrayIterator;
@@ -179,19 +282,19 @@ interface ArticleRepository
}
```
-Callers get `$articles->published()->totalWordCount()` — domain logic about the result set lives on the type, not scattered across services. `IteratorAggregate` / `Countable` give the wrapper standard "feels like an array" ergonomics. To compose a richer base, wrap a Laravel / Illuminate / Doctrine `Collection` via a property the same way.
+呼び出し側は `$articles->published()->totalWordCount()` のように扱えます。結果セットに関するドメインロジックはサービス層に散らばらず、型の上に置かれます。`IteratorAggregate` / `Countable` を実装すれば、標準的な「配列らしい」操作感も得られます。よりリッチな基盤が必要なら、Laravel / Illuminate / Doctrine の `Collection` をプロパティとして包むのも同じ考え方です。
-`$rows` shape is determined by what the framework hands the wrapper:
+`$rows` の形はフレームワークがラッパーに渡すものによって決まります。
-- `@return Articles` docblock or `factory:` attribute → entity instances.
-- Neither declared → associative arrays.
-- DML statement → `[]` (no fetch happens).
+- `@return Articles` docblock または `factory:` 属性 → Entity インスタンス。
+- どちらもない → 連想配列。
+- DML statement → `[]`。fetch は行われません。
-`$rows === []` therefore means either "DML, didn't fetch" or "SELECT, no matches" — pick a result class scoped to one or the other rather than trying to handle both shapes.
+したがって `$rows === []` は「DML なので fetch していない」場合と「SELECT だが一致行がない」場合の両方を表せます。両方を 1 つの結果クラスで無理に扱うより、用途ごとに結果クラスを分ける方が明確です。
-**Generic base for reuse across repositories:**
+**再利用できる generic base:**
-Lift the entity out as a type variable when several repositories want the same shape with different entities. Psalm and PHPStan propagate the parameter through `foreach`, `$rows[N]`, and `iterator_to_array(...)`:
+複数の repository で同じ形を使い、Entity だけが違う場合は、Entity を型変数として抜き出します。Psalm と PHPStan は `foreach`、`$rows[N]`、`iterator_to_array(...)` まで型パラメータを伝播します。
```php
/**
@@ -228,11 +331,11 @@ final class Articles extends TypedRows
final class Users extends TypedRows {}
```
-`@extends TypedRows` carries `Article` through to every site that inspects the rows — `$articles->rows[0]->title`, `foreach ($articles as $a) { $a->wordCount; }`, and any derived method on the base. The framework still hands `$context->rows` as `array`; the narrow happens at the `@var list` line in `fromContext()`, and from that point on the static analyser honours the parameter. Runtime is identical to the single-type wrapper above — PHP has no native generics, so this is a static-analysis claim, not a runtime check.
+`@extends TypedRows` により、`Article` は row を調べるすべての場所へ伝わります。`$articles->rows[0]->title`、`foreach ($articles as $a) { $a->wordCount; }`、base class 上の派生メソッドも同様です。フレームワークが `$context->rows` として渡す型は実行時には `array` のままです。`fromContext()` 内の `@var list` で narrow し、その後は静的解析器が型パラメータを尊重します。PHP にはネイティブ generic がないため、これは実行時チェックではなく静的解析上の主張です。
-**Constructor Property Promotion (Recommended):**
+**Constructor Property Promotion (推奨):**
-Use constructor property promotion for type-safe, immutable entities:
+型安全で immutable な Entity には constructor property promotion を使います。
```php
final class Invoice
@@ -253,7 +356,7 @@ final class Invoice
// property name and needs a SQL alias instead.)
```
-For PHP 8.4+, use readonly classes:
+PHP 8.4 以降では readonly class も使えます。
```php
final readonly class Invoice
@@ -267,13 +370,13 @@ final readonly class Invoice
}
```
-### Factory Pattern for Complex Objects
+### 複雑なオブジェクトのための Factory Pattern
-Use factories when entities need computed properties or injected services:
+計算済みプロパティや注入サービスが必要な Entity には factory を使います。
-**Keep domain knowledge out of controllers:**
+**ドメイン知識を controller から出す:**
-The database may store only `birth_date`, while the object exposed to the application has `age`. The age calculation is domain knowledge — "full years as of today", timezone policy, and leap-day handling — and should not be repeated in controllers or templates.
+データベースには `birth_date` しか保存されていない一方で、アプリケーションに公開するオブジェクトには `age` が必要な場合があります。年齢計算は「今日時点の満年齢」、タイムゾーン方針、うるう日処理を含むドメイン知識であり、controller や template に繰り返し書くべきではありません。
```sql
-- sql/user_profile.sql
@@ -291,7 +394,7 @@ final class UserProfile
public function __construct(
public readonly string $id,
public readonly string $name,
- public readonly int $age, // not a database column
+ public readonly int $age, // database column ではない
) {}
}
@@ -326,7 +429,7 @@ interface UserProfileQuery
}
```
-The controller receives a `UserProfile` that already speaks the domain language:
+controller は、すでにドメイン語彙で語れる `UserProfile` を受け取ります。
```php
$profile = $userProfileQuery->profile($id);
@@ -337,9 +440,10 @@ return [
];
```
-No controller needs to know how `birth_date` becomes `age`; the transformation stays at the SQL/domain boundary.
+`birth_date` から `age` を作る方法を controller が知る必要はありません。変換は SQL / domain 境界に閉じ込められます。
+
+**基本的な factory:**
-**Basic Factory:**
```php
interface OrderRepository
{
@@ -354,14 +458,15 @@ class OrderFactory
return new Order(
id: $id,
amount: $amount,
- tax: $amount * 0.1, // Computed
- total: $amount * 1.1, // Computed
+ tax: $amount * 0.1, // 計算値
+ total: $amount * 1.1, // 計算値
);
}
}
```
-**Factory with Dependency Injection:**
+**依存注入を使う factory:**
+
```php
class OrderFactory
{
@@ -382,7 +487,8 @@ class OrderFactory
}
```
-**Polymorphic Entities:**
+**ポリモーフィック Entity:**
+
```php
class UserFactory
{
@@ -396,11 +502,12 @@ class UserFactory
}
```
-> **Architecture Pattern**: Factories enable the [**BDR Pattern**](https://github.com/ray-di/Ray.MediaQuery/blob/1.x/BDR_PATTERN.md) - combining efficient SQL with rich domain objects through dependency injection.
+> **Architecture Pattern**: factory は [**BDR Pattern**]({{ '/bdr-pattern/ja/' | relative_url }}) を実現します。効率的な SQL と、依存注入を通じて組み立てられるリッチなドメインオブジェクトを組み合わせる設計です。
-### Smart Parameter Handling
+### 賢いパラメータ処理
+
+**DateTime の自動変換:**
-**DateTime Automatic Conversion:**
```php
interface TaskRepository
{
@@ -409,11 +516,12 @@ interface TaskRepository
}
// SQL: INSERT INTO tasks (title, created_at) VALUES (:title, :createdAt)
-// DateTime converted to: '2024-01-15 10:30:00'
-// null injects current time automatically
+// DateTime は '2024-01-15 10:30:00' に変換される
+// null は現在時刻の自動注入を起動する
```
-**Value Objects:**
+**値オブジェクト:**
+
```php
class UserId implements ToScalarInterface
{
@@ -431,10 +539,11 @@ interface MemoRepository
public function add(string $memo, UserId $userId): void;
}
-// UserId automatically converted via toScalar()
+// UserId は toScalar() を通じて自動変換される
```
-**Parameter Injection:**
+**パラメータ注入:**
+
```php
interface TodoRepository
{
@@ -442,14 +551,14 @@ interface TodoRepository
public function add(string $title, Uuid|null $id = null): void;
}
-// null triggers DI: Uuid is generated and injected automatically
+// null により DI が起動し、Uuid が生成・注入される
```
### Input Object Flattening
-Structure your input while keeping SQL simple with `Ray.InputQuery`.
+`Ray.InputQuery` を使うと、入力を構造化しながら SQL は単純に保てます。
-> **Note**: This feature requires the `ray/input-query` package, which is already included as a dependency.
+> **Note**: この機能には `ray/input-query` package が必要です。Ray.MediaQuery には依存として含まれています。
```php
use Ray\InputQuery\Attribute\Input;
@@ -478,15 +587,16 @@ interface TodoRepository
public function create(TodoInput $input): void;
}
-// Input flattened automatically:
+// Input は自動的に flatten される:
// :title, :givenName, :familyName, :email, :dueDate
```
-### Pagination
+### ページネーション
+
+`#[Pager]` 属性で遅延ロードされるページネーションを有効にします。
-Enable lazy-loaded pagination with the `#[Pager]` attribute:
+**基本的なページネーション:**
-**Basic Pagination:**
```php
use Ray\MediaQuery\Annotation\DbQuery;
use Ray\MediaQuery\Annotation\Pager;
@@ -500,19 +610,20 @@ interface ProductRepository
}
$pages = $productRepo->getProducts();
-$count = count($pages); // Executes COUNT query
-$page = $pages[1]; // Executes SELECT with LIMIT/OFFSET
+$count = count($pages); // COUNT query を実行
+$page = $pages[1]; // LIMIT/OFFSET 付き SELECT を実行
// Page object properties:
-// $page->data // Items for this page
-// $page->current // Current page number
-// $page->total // Total number of items (same as count($pages))
-// $page->hasNext // Has next page?
-// $page->hasPrevious // Has previous page?
+// $page->data // このページの item
+// $page->current // 現在ページ番号
+// $page->total // 全 item 数 (count($pages) と同じ)
+// $page->hasNext // 次ページがあるか
+// $page->hasPrevious // 前ページがあるか
// (string) $page // Pager HTML
```
-**Dynamic Page Size:**
+**動的ページサイズ:**
+
```php
interface ProductRepository
{
@@ -522,7 +633,8 @@ interface ProductRepository
}
```
-**With Entity Hydration:**
+**Entity Hydration との併用:**
+
```php
interface ProductRepository
{
@@ -532,12 +644,12 @@ interface ProductRepository
public function getProducts(): Pages;
}
-// Each page's data is hydrated to Product entities
+// 各 page の data は Product entity に hydrate される
```
-### Direct SQL Execution
+### 直接 SQL 実行
-For advanced use cases, inject `SqlQueryInterface` directly:
+高度な用途では `SqlQueryInterface` を直接注入できます。
```php
use Ray\MediaQuery\SqlQueryInterface;
@@ -555,11 +667,12 @@ class CustomRepository
}
```
-**Available Methods:**
-- `getRow($queryId, $params)` - Single row
-- `getRowList($queryId, $params)` - Multiple rows
-- `exec($queryId, $params)` - Execute without result
-- `execPostQuery($queryId, $params, $postQueryClass, FetchInterface|null $fetch = null)` - Execute a SQL statement (SELECT or DML) and build a typed result via a `PostQueryInterface` class (e.g. `AffectedRows`, `InsertedRow`, a typed collection wrapper, or any custom class). When `$fetch` is supplied, SELECT rows arrive on the context already hydrated to that strategy's shape.
-- `getCount($queryId, $params)` - Total row count (for pagination)
-- `getStatement()` - Get PDO statement
-- `getPages()` - Get paginated results
+**利用可能なメソッド:**
+
+- `getRow($queryId, $params)` — 単一行を取得。
+- `getRowList($queryId, $params)` — 複数行を取得。
+- `exec($queryId, $params)` — 結果を受け取らずに実行。
+- `execPostQuery($queryId, $params, $postQueryClass, FetchInterface|null $fetch = null)` — SQL statement (SELECT または DML) を実行し、`PostQueryInterface` class を通じて型付き結果を構築します。`AffectedRows`、`InsertedRow`、型付き collection wrapper、任意の custom class などに使えます。`$fetch` を指定した場合、SELECT row はその strategy の形に hydrate された状態で context に渡ります。
+- `getCount($queryId, $params)` — 総行数を取得 (ページネーション用)。
+- `getStatement()` — PDO statement を取得。
+- `getPages()` — ページング結果を取得。
diff --git a/docs/tutorial/README.ja.md b/docs/tutorial/README.ja.md
index 1328661..8b6cadf 100644
--- a/docs/tutorial/README.ja.md
+++ b/docs/tutorial/README.ja.md
@@ -3,11 +3,13 @@ layout: default
title: Ray.MediaQuery ハンズオンチュートリアル
description: ブログサービスを題材に、Ray.MediaQuery 1.1.0 までの主要機能を第0章から第13章+補章で体験する入門
lang: ja
-permalink: /tutorial/
+permalink: /tutorial/ja/
---
# Ray.MediaQuery ハンズオンチュートリアル
+[English version](https://ray-di.github.io/Ray.MediaQuery/tutorial/)
+
ブログサービスを題材に、Ray.MediaQuery 1.1.0 までの主要機能を第0章から第13章+補章で体験する入門。
- 前提: PHP 8.2+ / Composer / SQL の基礎 / DI の概念
@@ -1733,7 +1735,7 @@ Ray.MediaQuery は、その Read 側を Query-first に分割する。`UserRepos
ここまで読み終えると、Ray.MediaQuery の主要機能を一通り体験したことになる。
-このハンズオンは「interface + SQL + 戻り値型でアプリケーションの Query 契約を作る」理解を優先している。以下は本文では実装せず、Feature Reference で確認する発展機能である。
+このハンズオンは「interface + SQL + 戻り値型でアプリケーションの Query 契約を作る」理解を優先している。以下は本文では実装せず、Manual で確認する発展機能である。
- `#[Input]` Object Flattening — 入力 DTO を SQL パラメータへ平坦化する。
- `SqlQueryInterface` 直接実行 — interface 経由ではなく、低レベル API として SQL を実行する。
@@ -1744,7 +1746,7 @@ Ray.MediaQuery は、その Read 側を Query-first に分割する。`UserRepos
### 次に読むもの
- [BDR Pattern Guide 日本語版](https://github.com/ray-di/Ray.MediaQuery/blob/1.x/BDR_PATTERN-ja.md) — ファクトリパターンとドメインオブジェクトの設計
-- [Feature Reference](https://ray-di.github.io/Ray.MediaQuery/reference/) — 機能リファレンス (`#[Input]` Object Flattening, `SqlQueryInterface` 直接実行などの応用)
+- [Manual](https://ray-di.github.io/Ray.MediaQuery/reference/) — マニュアル (`#[Input]` Object Flattening, `SqlQueryInterface` 直接実行などの応用)
- [llms-full.txt](../llms-full.txt) — AI エージェント向けの圧縮リファレンス
- [`tests/Fake/`](https://github.com/ray-di/Ray.MediaQuery/tree/1.x/tests/Fake) — 実際のテストコード
diff --git a/docs/tutorial/README.md b/docs/tutorial/README.md
new file mode 100644
index 0000000..c256d29
--- /dev/null
+++ b/docs/tutorial/README.md
@@ -0,0 +1,1756 @@
+---
+layout: default
+title: Ray.MediaQuery Hands-on Tutorial
+description: A hands-on introduction that builds a SQLite blog service while covering the main Ray.MediaQuery features through 1.1.0.
+lang: en
+permalink: /tutorial/
+---
+
+# Ray.MediaQuery Hands-on Tutorial
+
+This tutorial builds a small blog service and walks through the major Ray.MediaQuery features through 1.1.0.
+
+- Requirements: PHP 8.2+, Composer, basic SQL, and a basic understanding of DI
+- Database: SQLite (`:memory:`), so no additional database server is required
+
+[日本語版はこちら](https://ray-di.github.io/Ray.MediaQuery/tutorial/ja/)
+
+## How to Read This Tutorial
+
+Each chapter follows the same flow.
+
+1. **Goal** - what you will be able to do in the chapter
+2. **Steps** - write SQL, then an interface, then add code to `run.php`
+3. **Run and expected output** - execute your working `run.php` with `php mywork/run.php`
+4. **Explanation** - what the framework is doing
+5. **Next chapter**
+
+The completed source is available under [`docs/tutorial/src/`](https://github.com/ray-di/Ray.MediaQuery/tree/1.x/docs/tutorial/src). Treat it as the answer key when you get stuck.
+
+> **About the completed `run.php`**: [`docs/tutorial/src/run.php`](https://github.com/ray-di/Ray.MediaQuery/blob/1.x/docs/tutorial/src/run.php) is an integrated demo that runs the whole tutorial. Expected output sections are labeled either `(standalone)` or `(integrated run.php)`.
+>
+> - `(standalone)` means the output from a small `run.php` containing only the code for that chapter. The early feature introductions use this label.
+> - `(integrated run.php)` means the output from the completed demo after all previous inserts, updates, and deletes have accumulated. Later chapters depend on that state.
+>
+> Your own `run.php` may produce different ids and counts depending on which chapters you have accumulated. Check the type and structure, not just the exact number. The completed `run.php` executes chapters 1 through 12 plus the appendix. Chapter 13 is a testing-strategy chapter and has no demo code.
+
+This tutorial intentionally rewrites some method declarations as it progresses. For example, `add()` returns `AffectedRows` in chapter 3, `void` in chapter 6, and the final `InsertedRow` shape from chapter 10 onward. You will experience the intermediate forms first, then converge on the final interface.
+
+The tutorial assumes **Ray.MediaQuery 1.1.0 or later**. These 1.1.0 features are covered hands-on, so the later chapters do not work unchanged on the 1.0 series.
+
+- Typed result construction through `PostQueryInterface` for both DML and SELECT results
+- `AffectedRows` / `InsertedRow`
+- `#[Pager]` used together with `#[DbQuery(factory: ...)]` so rows inside a page are hydrated through the factory
+
+You do not need to complete everything at once. To get the implementation feel, chapters 0 through 6 are enough. To understand the 1.1 additions, read chapters 9 through 12 and the conclusion.
+
+## Return Type Cheat Sheet
+
+In Ray.MediaQuery, the **method return type** is the contract that tells the framework how to handle the result. The SQL kind is not the only signal.
+
+| Return type / docblock | Meaning |
+|------------------------|---------|
+| `array` | Return multiple rows as a list of associative arrays |
+| `?array` + `type: 'row'` | Return one associative row, or `null` if no row matches |
+| `/** @return array */ array` | Hydrate multiple rows into `Article` objects |
+| `?Article` + `type: 'row'` | Return one `Article` object, or `null` if no row matches |
+| `void` | Execute DML and ignore the result |
+| `AffectedRows` | Return the row count affected by INSERT / UPDATE / DELETE |
+| `InsertedRow` | Return the auto-increment id and the resolved bound values after INSERT |
+| `Pages` | Return a paginated `Article` list |
+| `PostQueryInterface` implementation | Build a custom result object from `PostQueryContext` after execution |
+
+The SQL examples use a readable "Holywell-lite" style.
+
+- SQL keywords are uppercase.
+- Table and column names use lowercase `snake_case`.
+- Multi-column `SELECT` lists use one column per line.
+- Indentation is 4 spaces.
+- Aliases use explicit `AS`.
+- SQL files in this tutorial end with `;`. In multi-statement SQL, every statement must end with `;`.
+- Short `INSERT` / `DELETE` statements stay on 1-2 lines for copyability.
+
+SQL placeholders are the exception: they match PHP argument names and therefore use camelCase, such as `:authorName`.
+
+## Completed Directory Structure
+
+The tree below is the completed answer under `docs/tutorial/src/` with namespace `Tutorial\Blog\`. Your own copied code goes under `mywork/` with namespace `MyBlog\` (see chapter 0). The namespaces differ, so both trees can coexist in the same repository.
+
+```text
+docs/tutorial/src/
+|-- run.php # Entry point that runs all chapters
+|-- schema.sql # Table definitions
+|-- Blog/
+| |-- Article.php
+| |-- ArticleQueryInterface.php
+| |-- Comment.php
+| |-- CommentQueryInterface.php
+| |-- ArticleId.php # ToScalarInterface implementation
+| |-- ArticleStats.php
+| |-- ArticleStatsFactory.php # DI factory
+| |-- MarkdownExcerpter.php # Injected into the factory
+| |-- ArticleSearchResult.php # SELECT PostQueryInterface
+| `-- CreatedArticle.php # DML + SELECT PostQueryInterface
+`-- sql/
+ |-- article_add.sql
+ |-- article_create_and_get.sql
+ |-- article_item.sql
+ |-- article_list.sql
+ |-- article_update.sql
+ |-- article_delete.sql
+ |-- article_paginated.sql
+ |-- article_search.sql
+ |-- article_stats.sql
+ |-- article_stats_paginated.sql
+ |-- comment_add.sql
+ `-- comment_list.sql
+```
+
+## Table of Contents
+
+| Chapter | Title | Feature |
+|---------|-------|---------|
+| [Chapter 0](#chapter-0-setup) | Setup | autoload, SQLite `:memory:` |
+| [Chapter 1](#chapter-1-first-query-listing-rows) | First query: listing rows | `#[DbQuery]` / SELECT row list |
+| [Chapter 2](#chapter-2-fetching-one-row) | Fetching one row | `#[DbQuery(type: 'row')]` |
+| [Chapter 3](#chapter-3-insert-and-affectedrows) | INSERT and AffectedRows | INSERT / `AffectedRows` |
+| [Chapter 4](#chapter-4-automatic-entity-mapping) | Automatic entity mapping | Constructor promotion / readonly |
+| [Chapter 5](#chapter-5-constructor-hydration-and-select-column-order) | Constructor hydration and SELECT column order | `FetchNewInstance` / hydration path |
+| [Chapter 6](#chapter-6-datetime-and-toscalar) | DateTime and ToScalar | `DateTimeInterface` / `ToScalarInterface` |
+| [Chapter 7](#chapter-7-building-derived-values-with-a-factory) | Building derived values with a factory | `factory:` static factory |
+| [Chapter 8](#chapter-8-injecting-dependencies-into-a-factory) | Injecting dependencies into a factory | `factory:` DI factory |
+| [Chapter 9](#chapter-9-update--delete-and-affected-row-counts) | UPDATE / DELETE and affected row counts | `AffectedRows` |
+| [Chapter 10](#chapter-10-getting-the-id-and-final-values-from-insert) | Getting id and final values from INSERT | `InsertedRow` |
+| [Chapter 11](#chapter-11-pagination) | Pagination | `#[Pager]` / `Pages` / factory hydration |
+| [Chapter 12](#chapter-12-custom-postqueryinterface) | Custom PostQueryInterface | SELECT-capable `PostQueryInterface::fromContext()` |
+| [Chapter 13](#chapter-13-testing-strategy) | Testing strategy | fake bindings |
+| [Appendix](#appendix-multi-statement-dml--select) | Multi-statement DML + SELECT | one method for INSERT + SELECT |
+| [Conclusion](#conclusion-query-first-vs-repository-pattern) | Query-first vs Repository Pattern | query contracts and CQRS read models |
+
+---
+
+## Chapter 0: Setup
+
+### Goal
+
+- Create a working directory
+- Prepare Composer autoloading
+- Initialize an empty schema in an in-memory SQLite database
+
+### Step 1. Clone the repository and install dependencies
+
+```bash
+php -m | grep '^pdo_sqlite$'
+git clone https://github.com/ray-di/Ray.MediaQuery.git
+cd Ray.MediaQuery
+composer install
+```
+
+If `pdo_sqlite` is printed, you are ready.
+
+### Step 2. Create the working tree
+
+Write your code in `mywork/` with namespace `MyBlog\`, separate from the answer tree (`docs/tutorial/src/`, namespace `Tutorial\Blog\`). This avoids collisions and mirrors a real project where you install the library and write your own application code.
+
+From the repository root:
+
+```bash
+mkdir -p mywork/blog mywork/sql
+```
+
+From now on, `Blog/Xxx.php` means `mywork/blog/Xxx.php`, `sql/xxx.sql` means `mywork/sql/xxx.sql`, and `run.php` means `mywork/run.php`.
+
+### Step 3. Add autoloading to `composer.json`
+
+Map the `MyBlog\` namespace to `mywork/blog/`. This is not strictly required for `run.php` because the bootstrap below calls `$loader->addPsr4()`, but it is useful for IDE completion and PHPUnit.
+
+```json
+{
+ "autoload-dev": {
+ "psr-4": {
+ "MyBlog\\": "mywork/blog/"
+ }
+ }
+}
+```
+
+Apply it with:
+
+```bash
+composer dump-autoload
+```
+
+> The repository's actual `composer.json` already contains `"Tutorial\\Blog\\": "docs/tutorial/src/Blog/"` for the answer code. Leave it as-is. Your code uses the separate `MyBlog\` namespace, so the two mappings can coexist.
+
+### Step 4. Schema
+
+`mywork/schema.sql`:
+
+```sql
+CREATE TABLE IF NOT EXISTS article (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ body TEXT NOT NULL,
+ author_name TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'draft',
+ published_at TEXT,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS comment (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ article_id INTEGER NOT NULL,
+ body TEXT NOT NULL,
+ posted_at TEXT NOT NULL
+);
+```
+
+### Explanation
+
+- **`AUTOINCREMENT`**: chapter 10 uses `InsertedRow::$id`, so `id` is auto-generated from the start.
+- **`published_at TEXT`**: SQLite has no native `DATETIME` type. Dates are stored as strings. Chapter 6 shows how `DateTimeImmutable` is converted into a `'Y-m-d H:i:s'` string.
+
+In chapter 1, you start writing query code.
+
+---
+
+## Chapter 1: First Query: Listing Rows
+
+### Goal
+
+- Attach `#[DbQuery('id')]` to an interface method, place `id.sql`, and experience a working query with no implementation class.
+- Use return type `array` to fetch multiple rows as associative arrays.
+
+### Step 1. Write SQL
+
+`mywork/sql/article_list.sql`:
+
+```sql
+SELECT
+ id,
+ title,
+ body,
+ author_name,
+ status,
+ published_at,
+ created_at
+FROM article
+ORDER BY id;
+```
+
+### Step 2. Write the interface
+
+`mywork/blog/ArticleQueryInterface.php`:
+
+```php
+ repository vendor
+$loader->addPsr4('MyBlog\\', __DIR__ . '/blog'); // your code, separate from the answer namespace
+
+$sqlDir = __DIR__ . '/sql';
+$dsn = 'sqlite::memory:';
+
+$injector = new Injector(new class ($sqlDir, $dsn) extends AbstractModule {
+ public function __construct(
+ private readonly string $sqlDir,
+ private readonly string $dsn,
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ $queries = Queries::fromClasses([
+ ArticleQueryInterface::class,
+ ]);
+ $this->install(new MediaQueryModule($queries, [new DbQueryConfig($this->sqlDir)]));
+ $this->install(new AuraSqlModule($this->dsn));
+ }
+});
+
+/** @var ExtendedPdoInterface $pdo */
+$pdo = $injector->getInstance(ExtendedPdoInterface::class);
+foreach (preg_split('/;\\s*/', trim((string) file_get_contents(__DIR__ . '/schema.sql'))) ?: [] as $stmt) {
+ if ($stmt !== '') {
+ $pdo->query($stmt);
+ }
+}
+
+// Seed one row and list articles.
+$pdo->perform(
+ 'INSERT INTO article (title, body, author_name, status, created_at) VALUES (?, ?, ?, ?, ?)',
+ ['Hello', 'first body', 'Alice', 'published', '2026-04-01 09:00:00'],
+);
+
+/** @var ArticleQueryInterface $articleQuery */
+$articleQuery = $injector->getInstance(ArticleQueryInterface::class);
+
+var_dump($articleQuery->list());
+```
+
+### Run
+
+```bash
+php mywork/run.php
+```
+
+> The `dirname(__DIR__)` bootstrap assumes `mywork/run.php` is one level below the repository root. If you place `mywork/` elsewhere, replace that part with the correct path to the repository's `vendor/autoload.php`.
+
+### Expected Output (chapter 1 / standalone)
+
+```text
+array(1) {
+ [0]=>
+ array(7) {
+ ["id"]=>
+ int(1)
+ ["title"]=>
+ string(5) "Hello"
+ ["body"]=>
+ string(10) "first body"
+ ["author_name"]=>
+ string(5) "Alice"
+ ["status"]=>
+ string(9) "published"
+ ["published_at"]=>
+ NULL
+ ["created_at"]=>
+ string(19) "2026-04-01 09:00:00"
+ }
+}
+```
+
+> Depending on the environment, `id` may be returned as `string(1) "1"` instead of `int(1)` (for example with older `PDO::ATTR_STRINGIFY_FETCHES` settings or DSN options). On PHP 8.1+ with standard settings, `int(1)` is expected.
+
+### Explanation
+
+`ArticleQueryInterface` has no implementation class. Yet `$injector->getInstance(ArticleQueryInterface::class)` returns an instance. Ray.Aop intercepts methods marked with `#[DbQuery]`, reads `article_list.sql`, and supplies an automatically generated implementation.
+
+- `#[DbQuery('article_list')]` maps to `sql/article_list.sql` without the extension.
+- Return type `array` means "a list of associative rows". Ray.MediaQuery's core behavior is driven by the return type.
+- Column names are the raw SQLite names such as `author_name` and `published_at`. Chapter 4 hydrates them into an Entity, and chapter 5 explains the hydration path.
+
+---
+
+## Chapter 2: Fetching One Row
+
+### Goal
+
+- Use `type: 'row'` for SQL that returns one row.
+- Use return type `?array` (`array|null`) to receive one associative array, or `null` if no row matches.
+
+### Step 1. Write SQL
+
+`sql/article_item.sql`:
+
+```sql
+SELECT
+ id,
+ title,
+ body,
+ author_name,
+ status,
+ published_at,
+ created_at
+FROM article
+WHERE id = :id;
+```
+
+### Step 2. Add a method to the interface
+
+`Blog/ArticleQueryInterface.php`:
+
+```php
+#[DbQuery('article_item', type: 'row')]
+public function item(int $id): array|null;
+```
+
+### Step 3. Add to `run.php`
+
+```php
+$row = $articleQuery->item(1);
+var_dump($row);
+```
+
+### Expected Output (chapter 2 / standalone)
+
+```text
+array(7) {
+ ["id"]=>
+ int(1)
+ ["title"]=>
+ string(5) "Hello"
+ ...
+}
+```
+
+### Explanation
+
+- `type: 'row'` selects single-row mode, similar to `fetch()`.
+- The default is `type: 'row_list'`, similar to `fetchAll()`.
+- Even for the same SQL file, the result shape changes according to the return type and `type` setting.
+
+---
+
+## Chapter 3: INSERT and AffectedRows
+
+### Goal
+
+- Use the Ray.MediaQuery 1.1 `AffectedRows` result to receive the row count from your first write query.
+- Confirm that DML intent is also declared by the method return type.
+
+### Step 1. Write SQL
+
+`sql/article_add.sql`:
+
+```sql
+INSERT INTO article (title, body, author_name, status, published_at, created_at)
+VALUES (:title, :body, :authorName, :status, :publishedAt, :createdAt);
+```
+
+### Step 2. Add a method to the interface
+
+In this chapter, `add()` returns `AffectedRows`. Later chapters temporarily change it to `void`, then to its final `InsertedRow` form. The completed [`ArticleQueryInterface.php`](https://github.com/ray-di/Ray.MediaQuery/blob/1.x/docs/tutorial/src/Blog/ArticleQueryInterface.php) contains the chapter 10 `InsertedRow` version.
+
+```php
+use Ray\MediaQuery\Result\AffectedRows;
+
+#[DbQuery('article_add')]
+public function add(
+ string $title,
+ string $body,
+ string $authorName,
+ string $status,
+ string|null $publishedAt,
+ string $createdAt,
+): AffectedRows;
+```
+
+### Step 3. Add to `run.php`
+
+```php
+$affected = $articleQuery->add(
+ title: 'Second',
+ body: 'about SQL and Objects',
+ authorName: 'Bob',
+ status: 'published',
+ publishedAt: '2026-04-02 10:00:00',
+ createdAt: '2026-04-02 10:00:00',
+);
+printf("insert affected=%d\n", $affected->count);
+var_dump($articleQuery->list());
+```
+
+### Expected Output (chapter 3 / standalone)
+
+```text
+insert affected=1
+array(2) {
+ [0] => array(7) { ... "Hello" ... }
+ [1] => array(7) { ... "Second" ... }
+}
+```
+
+### Explanation
+
+- Method argument names (`$title`, `$authorName`, and so on) are bound to SQL placeholders with the same names (`:title`, `:authorName`). Argument order does not matter.
+- Return type `AffectedRows` declares "I want the DML affected row count." It works for INSERT, UPDATE, and DELETE.
+- Chapter 10 changes the same INSERT to `InsertedRow` so you can receive both the auto-generated id and the resolved bound values.
+
+---
+
+## Chapter 4: Automatic Entity Mapping
+
+### Goal
+
+- Receive results as `Article` objects instead of associative arrays.
+- Write immutable Entities with constructor property promotion and `readonly`.
+
+### Step 1. Write the Entity
+
+`Blog/Article.php`:
+
+```php
+ */
+#[DbQuery('article_list')]
+public function list(): array;
+
+#[DbQuery('article_item', type: 'row')]
+public function item(int $id): Article|null;
+```
+
+### Step 3. Rewrite `run.php`
+
+> **About assembling snippets**: Each chapter shows fragments to add or replace in `run.php`; a fragment is not a complete file on its own. When assembling, keep writes such as INSERT before the `list()` / `item()` reads that depend on them. The completed [`docs/tutorial/src/run.php`](https://github.com/ray-di/Ray.MediaQuery/blob/1.x/docs/tutorial/src/run.php) is one possible integrated version, but it is organized for the full demo and will not match your learning file line by line.
+
+Replace the earlier `var_dump($articleQuery->list())` and `var_dump($row)` calls with Entity-aware code.
+
+```php
+$articles = $articleQuery->list();
+foreach ($articles as $a) {
+ printf("[%d] %s by %s\n", $a->id, $a->title, $a->authorName);
+}
+
+$first = $articleQuery->item(1);
+echo $first?->title, "\n";
+```
+
+### Expected Output (chapter 4 / standalone)
+
+```text
+[1] Hello by Alice
+[2] Second by Bob
+Hello
+```
+
+### Explanation
+
+- The framework sees return type `Article|null` for one row and docblock `@return array` for many rows, then builds `Article` objects.
+- Because `Article` has a constructor, Ray.MediaQuery chooses `FetchNewInstance` and builds objects through `PDO::FETCH_FUNC`. **SELECT column order is passed directly to constructor argument order.** Chapter 5 covers this in detail.
+- Constructor promotion removes the need for getters and setters. `readonly` prevents accidental mutation.
+- On PHP 8.4+, `final readonly class Article { ... }` is even shorter.
+
+---
+
+## Chapter 5: Constructor Hydration and SELECT Column Order
+
+### Goal
+
+- Understand what contract made chapter 4 work.
+- Learn that Entities with and without constructors use different hydration paths.
+- Learn when SQL aliases are needed for snake_case columns and camelCase properties.
+
+### Key Point
+
+When a returned Entity has a constructor, Ray.MediaQuery uses `PDO::FETCH_FUNC` and passes each selected column **from left to right** into the constructor (`FetchNewInstance`).
+
+```sql
+-- article_list.sql
+SELECT id, title, body, author_name, status, published_at, created_at
+FROM article
+```
+
+```php
+final class Article
+{
+ public function __construct(
+ public readonly int $id, // SELECT column 1
+ public readonly string $title, // column 2
+ public readonly string $body, // column 3
+ public readonly string $authorName,// column 4; DB column is author_name, but order is what matters
+ public readonly string $status,
+ public readonly string|null $publishedAt,
+ public readonly string $createdAt,
+ ) {}
+}
+```
+
+This does **not** work because column names happen to match argument names. It works because the order of `SELECT id, title, body, author_name, ...` matches the order of `__construct(int $id, string $title, string $body, string $authorName, ...)`. If the SQL is changed to `SELECT title, id, ...`, `$id` receives a title string and `$title` receives an integer id, which produces a `TypeError`.
+
+### Try Breaking It Once
+
+Temporarily change `sql/article_list.sql` to the broken order below, run it, then change it back.
+
+```sql
+-- Broken example
+SELECT title, id, body, author_name, status, published_at, created_at
+FROM article;
+```
+
+```text
+TypeError: MyBlog\Article::__construct(): Argument #1 ($id) must be of type int, string given
+```
+
+### Entities Without Constructors
+
+When an Entity has no constructor, Ray.MediaQuery uses the `FetchClass` path (`PDO::FETCH_CLASS`). That path assigns values to properties with the **same names as the columns**. The framework does not convert snake_case to camelCase.
+
+```php
+// Entity without constructor
+final class ArticleBag
+{
+ public string $id;
+ public string $title;
+ public string $author_name; // same as the column name
+ public string|null $published_at; // same as the column name
+ // ...
+}
+```
+
+If PHP properties should be camelCase, add aliases in SQL.
+
+```sql
+SELECT id, title, author_name AS authorName, published_at AS publishedAt
+FROM article
+```
+
+Modern PHP code often prefers readonly immutable Entities with constructor promotion, which means it usually chooses `FetchNewInstance` and therefore relies on the practical contract **"SELECT column order = constructor argument order."**
+
+### Explanation
+
+- `FetchFactory::factory()` selects the hydration path from the return type and whether the Entity has a constructor. The implementation is in `src/FetchFactory.php`.
+- There are five paths: `FetchAssoc` (no Entity), `FetchClass` (Entity without constructor), `FetchNewInstance` (Entity with constructor), `FetchStaticFactory`, and `FetchInjectionFactory` (both selected by `factory:`, covered in chapters 7-8).
+- `factory:` also uses `PDO::FETCH_FUNC`, so **SELECT column order is passed to the factory method argument order**.
+- The order-based contract is manageable: when SQL changes, review the return Entity at the same time. IDEs, PHPStan, and Psalm will catch many mistakes early.
+
+---
+
+## Chapter 6: DateTime and ToScalar
+
+### Goal
+
+- Pass `DateTimeImmutable` directly and see it converted to a SQL string.
+- Convert a value object (`ArticleId`) to a scalar through `ToScalarInterface`.
+- Understand automatic injection through `null` defaults.
+
+### Step 1. Write the ArticleId value object
+
+`Blog/ArticleId.php`:
+
+```php
+value;
+ }
+}
+```
+
+### Step 2. Rewrite the interface
+
+In this chapter, change `add()` to return `void` so the focus stays on `DateTimeInterface` conversion. Chapter 10 changes it back to `InsertedRow` to observe the id and final bound values.
+
+Also change `item()` from `int $id` to `ArticleId $id`.
+
+```php
+use DateTimeInterface;
+
+#[DbQuery('article_item', type: 'row')]
+public function item(ArticleId $id): Article|null;
+
+#[DbQuery('article_add')]
+public function add(
+ string $title,
+ string $body,
+ string $authorName,
+ string $status = 'draft',
+ DateTimeInterface|null $publishedAt = null,
+ DateTimeInterface|null $createdAt = null,
+): void;
+```
+
+### Step 3. Rewrite `run.php`
+
+Update earlier calls to match the new signatures.
+
+- The chapter 3 code `$affected = $articleQuery->add(...); printf("insert affected=%d\n", $affected->count);` no longer works because `add()` returns `void`. Delete the `$affected->count` line, or simply call `$articleQuery->add(...)`.
+- The chapter 3 call passed date strings such as `publishedAt: '2026-04-02 10:00:00'`. The new signature expects `DateTimeInterface`, so pass `new DateTimeImmutable('2026-04-02 10:00:00')`.
+- The chapter 4 call `$articleQuery->item(1)` becomes `$articleQuery->item(new ArticleId(1))`.
+
+```php
+use DateTimeImmutable;
+
+$articleQuery->add(
+ title: 'Third',
+ body: 'about DateTime',
+ authorName: 'Carol',
+ status: 'published',
+ publishedAt: new DateTimeImmutable('2026-04-03 11:00:00'),
+ createdAt: new DateTimeImmutable('2026-04-03 11:00:00'),
+);
+
+$article = $articleQuery->item(new ArticleId(3));
+var_dump($article->publishedAt);
+```
+
+### Expected Output (chapter 6 / standalone)
+
+```text
+string(19) "2026-04-03 11:00:00"
+```
+
+### Explanation
+
+- **DateTime to string**: `ParamConverter` detects `DateTimeInterface` and converts it to a `'Y-m-d H:i:s'` string before passing it to PDO.
+- **ToScalarInterface**: the return value from `ArticleId::toScalar()` is bound to `:id`. You can keep type-safe value objects in PHP and reduce them automatically at the SQL boundary.
+- **The `null` default trap**: when a parameter has a `null` default, such as `DateTimeInterface|null = null`, omitting the argument triggers Ray.Di injection through `ParamInjector`.
+ - **Omitting the argument does not mean "store NULL".** It means "nullable type with a default used by Ray.Di." When omitted, `ParamInjector` resolves the current time.
+ - To really store NULL, explicitly pass `publishedAt: null`, or split the use case into a different SQL / method.
+ - Chapter 10 uses `InsertedRow::$values` to observe the values after injection and conversion.
+
+> SQLite has no native `DATETIME` type, so values come back as strings. MySQL and PostgreSQL behavior depends on the database type and driver.
+
+---
+
+## Chapter 7: Building Derived Values with a Factory
+
+### Goal
+
+- Return an object that has derived values, such as `excerpt`, `commentCount`, and a `published` flag.
+- Use the `factory:` attribute to call a static factory method.
+
+### Step 1. ArticleStats Entity
+
+`Blog/ArticleStats.php`:
+
+```php
+ **Important**: factory method arguments receive values in **SELECT column order** (`PDO::FETCH_FUNC`). The order must match; argument names are not the binding contract.
+
+### Step 3. Static factory
+
+Start with the simplest static factory. Chapter 8 evolves it into a DI factory.
+
+> Later snippets that begin with `namespace MyBlog;` omit the `stats(new ArticleId(1));
+var_dump($stats);
+```
+
+### Expected Output (chapter 7 / standalone)
+
+```text
+object(MyBlog\ArticleStats)#... {
+ ["id"]=> int(1)
+ ["title"]=> string(5) "Hello"
+ ["excerpt"]=> string(...) "..."
+ ["commentCount"]=> int(0)
+ ["published"]=> bool(true)
+}
+```
+
+### Explanation
+
+- **Static factory vs DI factory**: if the factory method is `static`, Ray.MediaQuery uses `FetchStaticFactory`; if it is an instance method, it uses `FetchInjectionFactory`.
+- **Derived-value expressiveness**: values that are not database columns, such as `excerpt` or the `published` flag, can become part of the object's primary responsibility. Entities are not limited to raw row data.
+
+---
+
+## Chapter 8: Injecting Dependencies into a Factory
+
+### Goal
+
+- Inject dependencies into the factory and compute derived values through services. This is the core of the BDR pattern.
+
+### Step 1. Service to inject
+
+`Blog/MarkdownExcerpter.php`:
+
+```php
+excerpter->excerpt($body, 60),
+ commentCount: $commentCount,
+ published: $status === 'published',
+ );
+ }
+}
+```
+
+### Step 3. Bind `MarkdownExcerpter` in the Module
+
+Add this to the Module's `configure()` method in `run.php`.
+
+```php
+$this->bind(MarkdownExcerpter::class);
+```
+
+### Step 4. Add comment-related files
+
+To make `stats()` meaningful, add comments. This also revisits Entity hydration through an `array` return type.
+
+`Blog/Comment.php`:
+
+```php
+ */
+ #[DbQuery('comment_list')]
+ public function listFor(int $articleId): array;
+}
+```
+
+### Step 5. Use `commentQuery` in `run.php`
+
+Add `CommentQueryInterface::class` to `Queries::fromClasses()` and get the instance.
+
+```php
+$queries = Queries::fromClasses([
+ ArticleQueryInterface::class,
+ CommentQueryInterface::class,
+]);
+
+/** @var CommentQueryInterface $commentQuery */
+$commentQuery = $injector->getInstance(CommentQueryInterface::class);
+```
+
+Add comments, then verify `stats()` and `listFor()`.
+
+```php
+$commentQuery->add(1, 'Great post!', new DateTimeImmutable('2026-04-01 12:00:00'));
+$commentQuery->add(1, 'Thanks!', new DateTimeImmutable('2026-04-01 13:00:00'));
+
+$stats = $articleQuery->stats(new ArticleId(1));
+printf("commentCount=%d, excerpt='%s'\n", $stats->commentCount, $stats->excerpt);
+
+$comments = $commentQuery->listFor(1);
+printf("comments=%d, first body='%s' (id=%d)\n", count($comments), $comments[0]->body, $comments[0]->id);
+```
+
+### Expected Output (chapter 8 / integrated run.php)
+
+From here onward, article text and counts depend on accumulated previous chapters, so the expected output uses the completed integrated `run.php`.
+
+```text
+commentCount=2, excerpt='This is the first post about interface-driven SQL.'
+comments=2, first body='Great post!' (id=1)
+```
+
+### Explanation
+
+- The factory is instantiated through Ray.Di, so its constructor can receive services freely.
+- This is what distinguishes Ray.MediaQuery from a simple query mapper: **domain processing can be applied efficiently at the SQL result boundary**.
+- The Business Domain Repository pattern is described in [BDR_PATTERN.md](https://github.com/ray-di/Ray.MediaQuery/blob/1.x/BDR_PATTERN.md).
+
+---
+
+## Chapter 9: UPDATE / DELETE and Affected Row Counts
+
+### Goal
+
+- Use the same `AffectedRows` result from chapter 3 for UPDATE and DELETE.
+
+### Step 1. Write SQL
+
+`sql/article_update.sql`:
+
+```sql
+UPDATE article
+SET
+ title = :title,
+ body = :body
+WHERE id = :id;
+```
+
+`sql/article_delete.sql`:
+
+```sql
+DELETE FROM article
+WHERE id = :id;
+```
+
+### Step 2. Add methods to the interface
+
+```php
+use Ray\MediaQuery\Result\AffectedRows;
+
+#[DbQuery('article_update')]
+public function update(ArticleId $id, string $title, string $body): AffectedRows;
+
+#[DbQuery('article_delete')]
+public function delete(ArticleId $id): AffectedRows;
+```
+
+### Step 3. Use them in `run.php`
+
+```php
+$updated = $articleQuery->update(new ArticleId(1), 'Hello (edited)', 'updated body');
+printf("updated count=%d, isAffected=%s\n", $updated->count, $updated->isAffected() ? 'yes' : 'no');
+
+$deleted = $articleQuery->delete(new ArticleId(2));
+printf("deleted count=%d\n", $deleted->count);
+```
+
+### Expected Output (chapter 9 / integrated run.php)
+
+```text
+updated count=1, isAffected=yes
+deleted count=1
+```
+
+### Explanation
+
+- `AffectedRows` is a `final class` with a readonly `int $count` property and an `isAffected(): bool` method.
+- Declaring `AffectedRows` as the return type is enough for the framework to call `$statement->rowCount()` and construct the result.
+- As with chapter 3's INSERT, the framework does not infer intent from the SQL. The return type declares what you want to know.
+
+---
+
+## Chapter 10: Getting the ID and Final Values from INSERT
+
+### Goal
+
+- Use the Ray.MediaQuery 1.1 `InsertedRow` result to receive the auto-generated id and the values that the framework actually passed to the database.
+- Learn when `InsertedRow` is a better choice than chapter 3's `AffectedRows`.
+
+### Step 1. Rewrite the interface
+
+Change `add()` from chapter 3's `AffectedRows` and chapter 6's `void` to `InsertedRow`. This is the final `add()` shape used by the tutorial.
+
+```php
+use Ray\MediaQuery\Result\InsertedRow;
+
+#[DbQuery('article_add')]
+public function add(
+ string $title,
+ string $body,
+ string $authorName,
+ string $status = 'draft',
+ DateTimeInterface|null $publishedAt = null,
+ DateTimeInterface|null $createdAt = null,
+): InsertedRow;
+```
+
+### Step 2. Rewrite `run.php`
+
+Replace the chapter 6 `$articleQuery->add(...)` call that ignored the return value with code that captures `$inserted`.
+
+```php
+$inserted = $articleQuery->add(
+ title: 'Hello',
+ body: 'first body',
+ authorName: 'Alice',
+ status: 'published',
+ publishedAt: new DateTimeImmutable('2026-04-01 09:00:00'),
+ createdAt: new DateTimeImmutable('2026-04-01 09:00:00'),
+);
+
+printf("id=%s\n", $inserted->id);
+var_dump($inserted->values);
+```
+
+With this method declaration, omitting `publishedAt` or `createdAt` lets `ParamInjector` inject a `DateTimeInterface` value and lets `ParamConverter` convert it to a SQL string. **Omitting does not mean NULL.** Pass `publishedAt: null` explicitly when you want to store NULL.
+
+```php
+$draft = $articleQuery->add(
+ title: 'Draft',
+ body: 'createdAt is injected',
+ authorName: 'Dana',
+);
+
+var_dump($draft->values['createdAt']);
+```
+
+### Expected Output (chapter 10 / integrated run.php)
+
+In the integrated `run.php`, this `add('Hello', ...)` is the first INSERT, so `id=1`. If your own `run.php` has accumulated earlier INSERTs, the id will be larger.
+
+```text
+id=1
+array(6) {
+ ["title"]=> string(5) "Hello"
+ ["body"]=> string(10) "first body"
+ ["authorName"]=> string(5) "Alice"
+ ["status"]=> string(9) "published"
+ ["publishedAt"]=> string(19) "2026-04-01 09:00:00"
+ ["createdAt"]=> string(19) "2026-04-01 09:00:00"
+}
+string(19) "2026-04-25 12:34:56" // Example runtime timestamp
+```
+
+### Explanation
+
+- **`$inserted->id`**: the result of `pdo->lastInsertId()`. With an `AUTOINCREMENT` column, the new id is returned as a string.
+- **`$inserted->values`**: the values after `ParamConverter` / `ParamInjector` resolution. `DateTimeImmutable` has become a string, and `ToScalarInterface` values have been reduced to scalars. This is the only way the caller can observe those final bound values.
+- **Choosing the result type**:
+ - Need only the row count -> `AffectedRows`
+ - Need the id or final bound values -> `InsertedRow`
+ - Need nothing -> `void`
+
+---
+
+## Chapter 11: Pagination
+
+### Goal
+
+- Use `#[Pager]` to handle large result sets as `Pages`.
+- Keep Article entity hydration with `Pages`.
+- Confirm the Ray.MediaQuery 1.1 fix that combines `#[Pager]` with `factory:` hydration.
+
+### Step 1. SQL
+
+`sql/article_paginated.sql` can be the same as `article_list.sql`.
+
+```sql
+SELECT
+ id,
+ title,
+ body,
+ author_name,
+ status,
+ published_at,
+ created_at
+FROM article
+ORDER BY id;
+```
+
+### Step 2. Add to the interface
+
+```php
+use Ray\MediaQuery\Annotation\Pager;
+use Ray\MediaQuery\Pages;
+
+/** @return Pages */
+#[DbQuery('article_paginated')]
+#[Pager(perPage: 10)]
+public function paginated(): Pages;
+```
+
+### Step 3. Add more data in `run.php`
+
+```php
+for ($i = 3; $i <= 32; $i++) {
+ $articleQuery->add(
+ title: "Post #{$i}",
+ body: "Body for post {$i}.",
+ authorName: 'Carol',
+ status: 'published',
+ publishedAt: new DateTimeImmutable('2026-04-03 00:00:00'),
+ createdAt: new DateTimeImmutable('2026-04-03 00:00:00'),
+ );
+}
+
+$pages = $articleQuery->paginated();
+$page1 = $pages[1];
+
+printf("total items=%d\n", count($pages));
+printf("page 1 has %d items, hasNext=%s\n", count($page1->data), $page1->hasNext ? 'yes' : 'no');
+echo $page1->data[0]->title, "\n";
+```
+
+### Expected Output (chapter 11 / paginated)
+
+```text
+total items=31
+page 1 has 10 items, hasNext=yes
+Hello (edited)
+```
+
+### Step 4. Ray.MediaQuery 1.1: Combine Pager and Factory
+
+In 1.1.0, `#[DbQuery(factory: ...)]` is honored even on a query with `#[Pager]`. Rows inside `$page->data` are also built through the factory.
+
+`sql/article_stats_paginated.sql`:
+
+```sql
+SELECT
+ a.id,
+ a.title,
+ a.body,
+ (
+ SELECT COUNT(*)
+ FROM comment AS c
+ WHERE c.article_id = a.id
+ ) AS comment_count,
+ a.status
+FROM article AS a
+ORDER BY a.id;
+```
+
+Add to `Blog/ArticleQueryInterface.php`:
+
+```php
+/** @return Pages */
+#[DbQuery('article_stats_paginated', factory: ArticleStatsFactory::class)]
+#[Pager(perPage: 10)]
+public function statsPaginated(): Pages;
+```
+
+Verify it in `run.php`.
+
+```php
+$statsPages = $articleQuery->statsPaginated();
+$statsPage1 = $statsPages[1];
+$firstStats = $statsPage1->data[0];
+
+printf(
+ "first stats row=%s commentCount=%d excerpt='%s'\n",
+ $firstStats::class,
+ $firstStats->commentCount,
+ $firstStats->excerpt,
+);
+```
+
+### Expected Output (chapter 11 / statsPaginated)
+
+```text
+first stats row=MyBlog\ArticleStats commentCount=2 excerpt='Updated body.'
+```
+
+### Explanation
+
+- `count($pages)` returns the **total item count** from the COUNT query, not the number of pages.
+- `$pages[1]` accesses page 1 and lazily executes SELECT with LIMIT/OFFSET.
+- `$page->data` contains a list of `Article` objects because of `@return Pages`.
+- With `#[DbQuery(factory: ArticleStatsFactory::class)]` and `#[Pager]` together, Ray.MediaQuery 1.1+ builds each row in `$page->data` through `ArticleStatsFactory`.
+- As in chapter 7, **factory method arguments receive values in SELECT column order**. Pager does not change that contract.
+- `$page->hasNext`, `$page->hasPrevious`, and `$page->current` support UI logic. `(string) $page` renders pager HTML.
+- See the README for advanced variants such as dynamic page size (`perPage: 'perPage'`).
+
+---
+
+## Chapter 12: Custom PostQueryInterface
+
+### Goal
+
+- Build your own result type like `AffectedRows` or `InsertedRow`.
+- Use `PostQueryInterface`, extended in Ray.MediaQuery 1.1 to support SELECT.
+- Return a search result with "matched count + executed SQL string."
+
+### Step 1. SQL
+
+`sql/article_search.sql`:
+
+```sql
+SELECT
+ id,
+ title,
+ body,
+ author_name,
+ status,
+ published_at,
+ created_at
+FROM article
+WHERE
+ title LIKE :keyword
+ OR body LIKE :keyword
+ORDER BY id;
+```
+
+### Step 2. Write the result class
+
+First, add a domain-specific exception instead of throwing a generic `UnexpectedValueException` directly. A specific exception lets callers catch only this intent.
+
+`Blog/Exception/UnexpectedRowException.php`:
+
+```php
+ $rows */
+ public function __construct(
+ public readonly array $rows,
+ public readonly int $matched,
+ public readonly string $sql,
+ ) {
+ }
+
+ #[Override]
+ public static function fromContext(PostQueryContext $context): static
+ {
+ $matched = count($context->rows);
+ $rows = [];
+ foreach ($context->rows as $row) {
+ if (! $row instanceof Article) {
+ throw new UnexpectedRowException('ArticleSearchResult expects Article rows.');
+ }
+
+ $rows[] = $row;
+ }
+
+ return new static(
+ rows: $rows,
+ matched: $matched,
+ sql: $context->statement->queryString,
+ );
+ }
+}
+```
+
+### Step 3. Add to the interface
+
+```php
+/** @return ArticleSearchResult */
+#[DbQuery('article_search')]
+public function search(string $keyword): ArticleSearchResult;
+```
+
+The docblock `@return ArticleSearchResult` is the Entity hydration hint. It tells Ray.MediaQuery to place a list of `Article` objects into `$context->rows` before calling your result class. This is metadata for the framework and static analysis; it is not a PHP runtime generic.
+
+### Step 4. Use it in `run.php`
+
+```php
+$result = $articleQuery->search('%Post%');
+printf("matched=%d, sql contains 'LIKE'=%s\n", $result->matched, str_contains($result->sql, 'LIKE') ? 'yes' : 'no');
+echo "First hit: ", $result->rows[0]->title, "\n";
+```
+
+> `$result->sql` (`$context->statement->queryString`) is the SQL after it has been rewritten for execution. Aura.Sql rewrites repeated named placeholders, such as a second `:keyword`, to names like `:keyword__1`, and multi-statement splitting can remove the trailing `;`. Avoid exact string comparison against the SQL file. Use partial checks such as `str_contains($result->sql, 'LIKE')`.
+
+### Expected Output (chapter 12 / integrated run.php)
+
+```text
+matched=30, sql contains 'LIKE'=yes
+First hit: Post #3
+```
+
+### Explanation
+
+- `PostQueryInterface` has one contract: `static fromContext(PostQueryContext): static`.
+- `PostQueryContext` contains:
+ - `$context->statement` (`PDOStatement`) - `rowCount()`, `queryString`, and so on. `queryString` is the SQL after execution rewriting and may not exactly match the source SQL file.
+ - `$context->pdo` (`ExtendedPdoInterface`) - for operations such as `lastInsertId()`.
+ - `$context->values` - bound values after `ParamConverter` / `ParamInjector`.
+ - `$context->rows` - hydrated results for SELECT paths, or `[]` for DML paths.
+- This is a broad extension point for both **DML results** (such as `AffectedRows`, based on `rowCount()`) and **SELECT results** (such as typed wrappers based on `rows`).
+- See `src/Result/AffectedRows.php`, `src/Result/InsertedRow.php`, `tests/Fake/Result/Articles.php`, and `tests/Fake/Result/RowCountWithQuery.php` for examples.
+
+---
+
+## Chapter 13: Testing Strategy
+
+### Goal
+
+- Use the "interface as contract" architecture to test business logic.
+
+### Idea
+
+`ArticleQueryInterface` is the contract. In production, Ray.MediaQuery automatically generates an implementation that talks to SQLite or MySQL. In tests, bind a fake implementation and test logic without a database.
+
+### Step 1. Write a fake implementation
+
+For fake methods that are not used in a test, throw a domain-specific exception rather than a generic `\LogicException`.
+
+> This fake implements the final `ArticleQueryInterface`, including `createAndGet()` from the appendix. If you have not read the appendix yet, skip that method for now.
+
+> Place the fake at `mywork/blog/Test/FakeArticleQuery.php` with namespace `MyBlog\Test`, which maps to `mywork/blog/Test/` under PSR-4.
+
+`Blog/Exception/UnsupportedQueryException.php`:
+
+```php
+ */
+ private array $store = [];
+
+ public function list(): array { return array_values($this->store); }
+ public function item(ArticleId $id): Article|null { return $this->store[$id->value] ?? null; }
+
+ public function add(string $title, string $body, string $authorName, string $status = 'draft', DateTimeInterface|null $publishedAt = null, DateTimeInterface|null $createdAt = null): InsertedRow
+ {
+ $id = count($this->store) + 1;
+ $this->store[$id] = new Article($id, $title, $body, $authorName, $status, $publishedAt?->format('Y-m-d H:i:s'), $createdAt?->format('Y-m-d H:i:s') ?? '');
+
+ return new InsertedRow(
+ values: compact('title', 'body', 'authorName', 'status'),
+ id: (string) $id,
+ );
+ }
+
+ public function update(ArticleId $id, string $title, string $body): AffectedRows { /* ... */ return new AffectedRows(1); }
+ public function delete(ArticleId $id): AffectedRows { unset($this->store[$id->value]); return new AffectedRows(1); }
+ public function paginated(): Pages { throw new UnsupportedQueryException('not used in this test'); }
+ public function statsPaginated(): Pages { throw new UnsupportedQueryException('not used in this test'); }
+ public function stats(ArticleId $id): ArticleStats { throw new UnsupportedQueryException('not used'); }
+ public function search(string $keyword): ArticleSearchResult { throw new UnsupportedQueryException('not used'); }
+ public function createAndGet(string $title, string $body, string $authorName, string $status = 'draft', DateTimeInterface|null $createdAt = null): CreatedArticle
+ {
+ $inserted = $this->add($title, $body, $authorName, $status, null, $createdAt);
+
+ return new CreatedArticle($this->store[(int) $inserted->id]);
+ }
+}
+```
+
+### Step 2. Replace the binding in a Module
+
+```php
+$injector = new Injector(new class extends AbstractModule {
+ protected function configure(): void
+ {
+ $this->bind(ArticleQueryInterface::class)->to(FakeArticleQuery::class)->in(\Ray\Di\Scope::SINGLETON);
+ }
+});
+
+/** @var ArticleQueryInterface $articleQuery */
+$articleQuery = $injector->getInstance(ArticleQueryInterface::class);
+$articleQuery->add('T', 'B', 'A');
+assert($articleQuery->item(new ArticleId(1))->title === 'T');
+```
+
+> In the default PHP CLI configuration (`zend.assertions=-1`), `assert()` is removed at compile time and does not verify anything. For a quick manual check, run `php -d zend.assertions=1 mywork/run.php`. In real projects, use PHPUnit assertions instead.
+
+### Explanation
+
+- You can unit-test logic without a database.
+- `tests/Fake/Queries/` contains many fake interfaces used by Ray.MediaQuery's own tests and is useful reference material.
+- With PHPUnit, install it with `composer require --dev phpunit/phpunit`, extend `PHPUnit\Framework\TestCase`, and use the same Module replacement pattern. PHPUnit assertions such as `assertSame('T', ...)` do not depend on `zend.assertions`.
+
+---
+
+## Appendix: Multi-statement DML + SELECT
+
+The main Ray.MediaQuery features have now been covered. One more 1.1 feature is useful: `PostQueryInterface` can receive SELECT results, so you can express "INSERT, then SELECT the created row" as one method contract.
+
+### Goal
+
+- Put multiple statements (INSERT -> SELECT) in one SQL file.
+- Use 1.1's `PostQueryContext::$rows` to receive the final SELECT as hydrated Entities inside a custom `PostQueryInterface`.
+
+### Step 1. SQL
+
+A use case such as "create an article and return the created row" is often implemented by hand as INSERT, last insert id, SELECT, hydrate. Ray.MediaQuery can declare the whole interaction with SQL and a return type.
+
+`sql/article_create_and_get.sql`:
+
+```sql
+INSERT INTO article (title, body, author_name, status, created_at)
+VALUES (:title, :body, :authorName, :status, :createdAt);
+
+SELECT
+ id,
+ title,
+ body,
+ author_name,
+ status,
+ published_at,
+ created_at
+FROM article
+WHERE id = last_insert_rowid();
+```
+
+> `last_insert_rowid()` is SQLite-specific. MySQL uses `LAST_INSERT_ID()`, and PostgreSQL or SQLite 3.35+ can also use `INSERT ... RETURNING`. Ray.MediaQuery splits multiple statements by `;`, so the final SELECT should also end with `;`.
+
+`Blog/CreatedArticle.php`:
+
+```php
+rows[0] ?? null;
+ if (! $article instanceof Article) {
+ throw new UnexpectedRowException('CreatedArticle expects the final SELECT to return an Article row.');
+ }
+
+ return new static($article);
+ }
+}
+```
+
+`Blog/ArticleQueryInterface.php`:
+
+```php
+/** @return CreatedArticle */
+#[DbQuery('article_create_and_get')]
+public function createAndGet(
+ string $title,
+ string $body,
+ string $authorName,
+ string $status = 'draft',
+ DateTimeInterface|null $createdAt = null,
+): CreatedArticle;
+```
+
+`run.php`:
+
+```php
+$created = $articleQuery->createAndGet(
+ title: 'Created and fetched',
+ body: 'A multi-statement query can return the row created by its first statement.',
+ authorName: 'Eve',
+);
+
+printf(
+ "created article id=%d title='%s' status=%s\n",
+ $created->article->id,
+ $created->article->title,
+ $created->article->status,
+);
+```
+
+### Expected Output (appendix / integrated run.php)
+
+```text
+created article id=33 title='Created and fetched' status=draft
+```
+
+### Explanation
+
+- Statements in a SQL file are split by `;`. `createAndGet()` executes INSERT -> SELECT as **two statements in one method call**.
+- `PostQueryContext::$rows` contains the hydrated result when the final statement is SELECT. If the final statement is DML, it is `[]`.
+- `CreatedArticle::fromContext()` reads `$context->rows[0]` as an `Article`. The docblock `@return CreatedArticle` on `ArticleQueryInterface::createAndGet()` is the hydration hint.
+- This pattern works for "INSERT then immediately SELECT" and "UPDATE then return the latest row" style interactions.
+
+The important point is that `createAndGet()` is not a convenience method on an `ArticleRepository`; it is a typed Query contract for "create an article and return the created result."
+
+---
+
+## Conclusion: Query-first vs Repository Pattern
+
+Throughout this tutorial, queries were expressed with interface + attribute + SQL + return type, without writing repository implementation classes.
+
+This is not just a shorter Repository Pattern. A Repository abstracts a persisted object collection. Ray.MediaQuery abstracts an executable Query contract.
+
+| Viewpoint | Repository Pattern | Ray.MediaQuery |
+|-----------|--------------------|----------------|
+| Center | Entity / Aggregate | Query / UseCase |
+| Main use | Write Model; save and restore Aggregates | Read Model, Projection, CQRS Query side |
+| Implementation | Handwritten Repository class | Interface + Attribute + SQL |
+| Result processing | Procedures inside Repository implementation | `factory:` / `PostQueryInterface` |
+| SQL | Easy to bury inside implementation | Explicit SQL files |
+| Replacement | Replace Repository interface with fake / mock | Replace Query interface with fake / mock |
+
+Repositories are still useful. On the write side, where you restore, mutate, and save Aggregates, they remain a valid abstraction.
+
+On the read side, you often want a projection shaped for a specific use case. If an Entity-centered Repository expands to serve dashboards, search, admin screens, and analytics, too many entry points accumulate in one place.
+
+Ray.MediaQuery splits the read side query-first. Instead of adding methods to `UserRepository`, define contracts such as `UserDashboardQuery`, `ArticleSearchQuery`, and `MonthlyStatsQuery` for the interactions themselves.
+
+> **Simplification in this tutorial**: to keep the walkthrough compact, `list`, `item`, `add`, `update`, `delete`, `paginated`, `statsPaginated`, `stats`, `search`, and `createAndGet` are gathered into one `ArticleQueryInterface`. In a real project, split by use case, such as `ArticleSearchQueryInterface`, `ArticleStatsQueryInterface`, and `ArticleCommandInterface`. Fakes become smaller and responsibilities clearer.
+
+The appendix's DML + SELECT example pushes this query-first idea further. Use Repositories for write-side Aggregate persistence. Use query-first contracts for read-side projection retrieval and typed results after DML. Ray.MediaQuery exists to make the latter explicit with interfaces and SQL.
+
+---
+
+## You Finished the Walkthrough
+
+You have now experienced the main Ray.MediaQuery features.
+
+This hands-on tutorial focuses on understanding application Query contracts built from interface + SQL + return type. The following advanced features are not implemented here; see the Manual for details.
+
+- `#[Input]` Object Flattening - flatten input DTOs into SQL parameters.
+- Direct `SqlQueryInterface` execution - execute SQL through a lower-level API instead of an interface.
+- `#[Pager(perPage: 'perPage')]` - change page size dynamically through a method argument.
+- `MediaQuerySqlModule` - shorter module configuration that discovers Query interfaces from a directory.
+- `SqlTemplate` / `MediaQuerySqlTemplateModule` - advanced SQL execution template customization.
+
+### Next Reading
+
+- [BDR Pattern Guide](https://github.com/ray-di/Ray.MediaQuery/blob/1.x/BDR_PATTERN.md) - factory pattern and domain object design
+- [Manual 日本語版](https://ray-di.github.io/Ray.MediaQuery/reference/) - advanced feature reference, including `#[Input]` Object Flattening and direct `SqlQueryInterface` execution
+- [llms-full.txt](../llms-full.txt) - compact reference for AI agents
+- [`tests/Fake/`](https://github.com/ray-di/Ray.MediaQuery/tree/1.x/tests/Fake) - real test examples
+
+### Community
+
+- [Issues](https://github.com/ray-di/Ray.MediaQuery/issues)
+- [BEAR.Sunday](https://bearsunday.github.io/) - application framework that includes Ray.MediaQuery