From 348b75e76bd4938c46d335f5baf156a3358b4cd6 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 2 Sep 2026 09:38:21 +0900 Subject: [PATCH 1/4] Add BDR pattern cookbook and author-profile factory example to tutorial Add bdr-patterns.md / .ja.md cookbook covering per-row factory enrichment vs whole-result-set shaping via PostQueryInterface, and a DI-driven AuthorProfile example (age computed from birth_date and an injected DateTimeInterface) wired into the tutorial run.php. Co-Authored-By: Claude Opus 4.8 --- docs/tutorial/README.ja.md | 117 ++++++- docs/tutorial/README.md | 117 ++++++- docs/tutorial/bdr-patterns.ja.md | 301 ++++++++++++++++++ docs/tutorial/bdr-patterns.md | 301 ++++++++++++++++++ docs/tutorial/src/Blog/AuthorProfile.php | 16 + .../src/Blog/AuthorProfileFactory.php | 28 ++ .../src/Blog/AuthorQueryInterface.php | 13 + docs/tutorial/src/run.php | 33 +- docs/tutorial/src/schema.sql | 6 + docs/tutorial/src/sql/author_profile.sql | 6 + 10 files changed, 924 insertions(+), 14 deletions(-) create mode 100644 docs/tutorial/bdr-patterns.ja.md create mode 100644 docs/tutorial/bdr-patterns.md create mode 100644 docs/tutorial/src/Blog/AuthorProfile.php create mode 100644 docs/tutorial/src/Blog/AuthorProfileFactory.php create mode 100644 docs/tutorial/src/Blog/AuthorQueryInterface.php create mode 100644 docs/tutorial/src/sql/author_profile.sql diff --git a/docs/tutorial/README.ja.md b/docs/tutorial/README.ja.md index b056262..325e0bf 100644 --- a/docs/tutorial/README.ja.md +++ b/docs/tutorial/README.ja.md @@ -951,6 +951,120 @@ final class ArticleStatsFactory $this->bind(MarkdownExcerpter::class); ``` +### BDR の核心: DI が不可欠な例 + +`age`(年齢)はデータベースのカラムではない。`birth_date` と現在時刻から計算する — 現在時刻はファクトリの外から `DateTimeInterface` として注入するしかない。 + +`age`(年齢)はデータベースのカラムではない。`birth_date` と「現在時刻」の2つから計算する。現在時刻はファクトリの外から注入するしかない — `DateTimeInterface` として DI で受け取る。 + +`mywork/schema.sql` に `author` テーブルを追加: + +```sql +CREATE TABLE IF NOT EXISTS author ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + birth_date TEXT NOT NULL +); +``` + +`sql/author_profile.sql`: + +```sql +SELECT + id, + name, + birth_date +FROM author +WHERE id = :id; +``` + +`Blog/AuthorProfile.php`: + +```php +final class AuthorProfile +{ + public function __construct( + public readonly int $id, + public readonly string $name, + public readonly string $birthDate, + public readonly int $age, // カラムではない — ファクトリが計算する + ) {} +} +``` + +`Blog/AuthorProfileFactory.php`: + +```php +use DateTimeImmutable; +use DateTimeInterface; + +final class AuthorProfileFactory +{ + public function __construct( + private readonly DateTimeInterface $now, + ) {} + + public function factory(int $id, string $name, string $birthDate): AuthorProfile + { + $age = (new DateTimeImmutable($birthDate))->diff($this->now)->y; + + return new AuthorProfile( + id: $id, + name: $name, + birthDate: $birthDate, + age: $age, + ); + } +} +``` + +`Blog/AuthorQueryInterface.php`: + +```php +bind(MarkdownExcerpter::class); +``` + +`DateTimeInterface` は `MediaQueryModule` 内部で既に `DateTimeImmutable` に bind されているため、追加の bind は不要。 + +`run.php` でデータを挿入して呼び出す: + +```php +$pdo->perform('INSERT INTO author (name, birth_date) VALUES (?, ?)', ['Alice', '1990-06-15']); + +/** @var AuthorQueryInterface $authorQuery */ +$authorQuery = $injector->getInstance(AuthorQueryInterface::class); +$profile = $authorQuery->profile(1); +printf("name=%s birth_date=%s age=%d\n", $profile->name, $profile->birthDate, $profile->age); +``` + +### 期待出力 (第8章 / BDR フォーカス / 単独実行) + +```text +name=Alice birth_date=1990-06-15 age=35 +``` + +> `age` はクエリ境界でファクトリが `birth_date` と `DateTimeInterface $now` から計算する。35 という値は `birth_date = '1990-06-15'` と実行日 2026-06-06 の組み合わせで、毎年変わる。`MediaQueryModule` が `DateTimeInterface` を `DateTimeImmutable` に bind しているため追加設定は不要(注入のたびに `new DateTimeImmutable()` が生成され、常に現在時刻が渡る)。テストでは固定インスタンスで上書き bind すれば `age` を決定的にできる。 + +コントローラーとテンプレートは `$profile->age` と書くだけで値が手に入る — クエリ境界の外での計算はゼロ。これが BDR の本質: **エンティティは受け取った時点で完成している**。 + ### Step 4. Comment 関連のファイルを足す stats を意味あるものにするためにコメントが要る。ここで Comment Entity と CommentQueryInterface を作って、`add()` / `listFor()` の2メソッドで運用する。`listFor()` は第1章の `list()` と同じ `array` 型を返すので、Entity hydration の復習にもなる。 @@ -1277,7 +1391,7 @@ echo $page1->data[0]->title, "\n"; ```text total items=31 page 1 has 10 items, hasNext=yes -Hello (edited) +Hello, Ray.MediaQuery (edited) ``` ### Step 4. Ray.MediaQuery 1.1: Pager と factory を組み合わせる @@ -1749,6 +1863,7 @@ Ray.MediaQuery は、その Read 側を Query-first に分割する。`UserRepos ### 次に読むもの +- [BDR パターン集](bdr-patterns.ja.md) — 行ごとの加工(`factory:`)と結果セット全体の成形(`PostQueryInterface`): バッジ・enum・JOINグルーピング・ソート・SPLイテレータ・Null Object - [BDR Pattern Guide 日本語版](https://github.com/ray-di/Ray.MediaQuery/blob/1.x/BDR_PATTERN-ja.md) — ファクトリパターンとドメインオブジェクトの設計 - [Manual](https://ray-di.github.io/Ray.MediaQuery/reference/) — マニュアル (`#[Input]` Object Flattening, `SqlQueryInterface` 直接実行などの応用) - [llms-full.txt](../llms-full.txt) — AI エージェント向けの圧縮リファレンス diff --git a/docs/tutorial/README.md b/docs/tutorial/README.md index c256d29..aea565f 100644 --- a/docs/tutorial/README.md +++ b/docs/tutorial/README.md @@ -949,6 +949,120 @@ Add this to the Module's `configure()` method in `run.php`. $this->bind(MarkdownExcerpter::class); ``` +### BDR focus: why DI is necessary + +`age` is not a database column. It is computed from `birth_date` and the current time — the current time must be injected as `DateTimeInterface`. + +`age` is not a database column. It requires two inputs: the stored `birth_date` and the current time. The current time must come from outside the factory — injected as `DateTimeInterface`. + +Add an `author` table to `mywork/schema.sql`: + +```sql +CREATE TABLE IF NOT EXISTS author ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + birth_date TEXT NOT NULL +); +``` + +`sql/author_profile.sql`: + +```sql +SELECT + id, + name, + birth_date +FROM author +WHERE id = :id; +``` + +`Blog/AuthorProfile.php`: + +```php +final class AuthorProfile +{ + public function __construct( + public readonly int $id, + public readonly string $name, + public readonly string $birthDate, + public readonly int $age, // not a column — computed by the factory + ) {} +} +``` + +`Blog/AuthorProfileFactory.php`: + +```php +use DateTimeImmutable; +use DateTimeInterface; + +final class AuthorProfileFactory +{ + public function __construct( + private readonly DateTimeInterface $now, + ) {} + + public function factory(int $id, string $name, string $birthDate): AuthorProfile + { + $age = (new DateTimeImmutable($birthDate))->diff($this->now)->y; + + return new AuthorProfile( + id: $id, + name: $name, + birthDate: $birthDate, + age: $age, + ); + } +} +``` + +`Blog/AuthorQueryInterface.php`: + +```php +bind(MarkdownExcerpter::class); +``` + +`DateTimeInterface` is already bound to `DateTimeImmutable` inside `MediaQueryModule` — no extra binding is needed. + +Seed an author and call `profile()` in `run.php`: + +```php +$pdo->perform('INSERT INTO author (name, birth_date) VALUES (?, ?)', ['Alice', '1990-06-15']); + +/** @var AuthorQueryInterface $authorQuery */ +$authorQuery = $injector->getInstance(AuthorQueryInterface::class); +$profile = $authorQuery->profile(1); +printf("name=%s birth_date=%s age=%d\n", $profile->name, $profile->birthDate, $profile->age); +``` + +### Expected Output (chapter 8 / BDR focus / standalone) + +```text +name=Alice birth_date=1990-06-15 age=35 +``` + +> `age` is computed at the query boundary from `birth_date` and `DateTimeInterface $now`. The value above is based on `birth_date = '1990-06-15'` and a run date of 2026-06-06; it advances each year. `MediaQueryModule` already binds `DateTimeInterface` to `DateTimeImmutable` (resolved at inject time, not compile time). In tests, override that binding with a fixed instance to make `age` deterministic. + +The controller and template write `$profile->age` and receive a ready value — no calculation outside the query boundary. This is BDR: the entity arrives complete. + ### Step 4. Add comment-related files To make `stats()` meaningful, add comments. This also revisits Entity hydration through an `array` return type. @@ -1275,7 +1389,7 @@ echo $page1->data[0]->title, "\n"; ```text total items=31 page 1 has 10 items, hasNext=yes -Hello (edited) +Hello, Ray.MediaQuery (edited) ``` ### Step 4. Ray.MediaQuery 1.1: Combine Pager and Factory @@ -1745,6 +1859,7 @@ This hands-on tutorial focuses on understanding application Query contracts buil ### Next Reading +- [BDR Pattern Cookbook](bdr-patterns.md) - per-row enrichment (`factory:`) vs. whole-result-set shaping (`PostQueryInterface`): badges, enums, JOIN grouping, sorting, SPL iterators, Null Object - [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 diff --git a/docs/tutorial/bdr-patterns.ja.md b/docs/tutorial/bdr-patterns.ja.md new file mode 100644 index 0000000..bde2a5b --- /dev/null +++ b/docs/tutorial/bdr-patterns.ja.md @@ -0,0 +1,301 @@ +# BDR パターン集 + +Ray.MediaQuery には、SQL の結果をオブジェクトに変える機構が2つある。どちらを使うかで「できること」が変わる。 + +| 機構 | 渡されるもの | 形 | 用途 | +|---|---|---|---| +| **`factory:` 属性** | 行ごとに1回、カラムを引数で | 1行 → 1オブジェクト | 行の加工・enrichment | +| **`PostQueryInterface`** | 結果セット全体 | N行 → 1オブジェクト | 集約・コレクション・成形 | + +第1部は `factory:`、第2部は `PostQueryInterface`。混同するとコードは動かない。 + +--- + +# 第1部 — `factory:` 行ごとの加工 + +ファクトリは**1行につき1回**呼ばれ、SELECT のカラムが順番に引数で渡る。戻り値が行の数だけ並ぶ。チュートリアル第7章の `ArticleStatsFactory` がこれ。 + +```php +interface ArticleQueryInterface +{ + /** @return list
*/ + #[DbQuery('article_list', factory: ArticleFactory::class)] + public function list(): array; +} +``` + +## テンプレートの `if` が消える + +```twig +{# よくある光景 #} +{% if article.status == 'published' and article.publishedAt <= now %} + 公開中 +{% elseif article.status == 'draft' %} + 下書き +{% endif %} +``` + +ステータス判定がテンプレートに染み出している。ファクトリで解決する。 + +```php +final class ArticleFactory +{ + public function factory(int $id, string $title, string $status): Article + { + $badge = match($status) { + 'published' => 'badge', + 'draft' => 'badge badge--draft', + default => '', + }; + + return new Article(id: $id, title: $title, status: $status, badge: $badge); + } +} +``` + +```twig +{# テンプレートは表示するだけ #} +{{ article.status }} +``` + +## 文字列カラムを enum で受け取る + +DB の `status` は文字列。ファクトリで PHP の enum に変換すれば、型安全な比較になる。 + +```php +public function factory(int $id, string $status): Article +{ + return new Article(id: $id, status: Status::from($status)); +} +``` + +```twig +{# 文字列比較ではなく enum 比較 #} +{% if article.status == enum('App\\Status::Published') %}...{% endif %} +``` + +タイプミスは `Status::from()` の時点で例外になる。テンプレートに生の文字列が散らばらない。 + +## 表示用の値をエンティティに乗せる + +「本文の文字数から読了時間を出したい」「価格をカンマ区切りで表示したい」。ファクトリで計算して乗せる。 + +```php +public function factory(int $id, string $body, int $priceYen): Article +{ + return new Article( + id: $id, + readingMinutes: (int) ceil(mb_strlen($body) / 400), + priceFormatted: number_format($priceYen) . '円', + ); +} +``` + +```twig +{{ article.readingMinutes }}分で読めます +{{ article.priceFormatted }} +``` + +テンプレートに計算式がない。テストもファクトリ単体で書ける。 + +## 現在時刻・現在ユーザーを注入する + +ファクトリはコンストラクタで DI を受け取れる(第8章の `age` と同じ)。SQL のカラムにない値を、外から注入した依存で組み立てる。 + +```php +final class ArticleFactory +{ + public function __construct( + private readonly CurrentUserInterface $currentUser, + private readonly DateTimeInterface $now, + ) {} + + public function factory(int $id, int $authorId, string $publishedAt): Article + { + return new Article( + id: $id, + isOwn: $authorId === $this->currentUser->id(), + isNew: (new DateTimeImmutable($publishedAt)) > $this->now->modify('-7 days'), + ); + } +} +``` + +```twig +{% if article.isOwn %}編集{% endif %} +{% if article.isNew %}NEW{% endif %} +``` + +`CurrentUserInterface` は Ray.Di で bind する。テストでは `FakeCurrentUser` を差し込む。`DateTimeInterface` は `MediaQueryModule` が既に bind 済み。 + +--- + +# 第2部 — `PostQueryInterface` 結果セット全体の成形 + +行をまたぐ処理(グルーピング、ソート、絞り込み、空判定)は `factory:` ではできない。`factory:` は1行ずつしか見ないからだ。結果セット全体が要るときは `PostQueryInterface` を使う。クラスは戻り値型として宣言し、`fromContext()` に `$context->rows`(全行)が渡る。チュートリアル第12章の `ArticleSearchResult` がこれ。 + +```php +interface ArticleQueryInterface +{ + #[DbQuery('article_with_comments')] + public function withComments(): Articles; // Articles implements PostQueryInterface +} +``` + +## フラットな JOIN 結果を親子に組み立てる + +JOIN の結果は平らな行で返る。コメントを記事ごとにまとめるのは、テンプレートでもコントローラーでも面倒。`fromContext()` でまとめる。 + +```sql +SELECT a.id, a.title, c.id AS comment_id, c.body AS comment_body +FROM article a +LEFT JOIN comment c ON c.article_id = a.id +ORDER BY a.id +``` + +```php +final class Articles implements PostQueryInterface +{ + /** @param list
$items */ + public function __construct(public readonly array $items) {} + + public static function fromContext(PostQueryContext $context): static + { + $titles = []; + $comments = []; + foreach ($context->rows as $row) { + $id = $row['id']; + $titles[$id] ??= $row['title']; + if ($row['comment_id'] !== null) { + $comments[$id][] = new Comment((int) $row['comment_id'], (string) $row['comment_body']); + } + } + + $items = []; + foreach ($titles as $id => $title) { + $items[] = new Article((int) $id, (string) $title, $comments[$id] ?? []); + } + + return new static($items); + } +} +``` + +```twig +{% for article in articles.items %} +

{{ article.title }}

+ {% for comment in article.comments %}

{{ comment.body }}

{% endfor %} +{% endfor %} +``` + +1クエリでネストしたオブジェクトが返る。N+1 も、コントローラーでの手動グルーピングもない。 + +## ソートは `fromContext()` で + +SQL の `ORDER BY` では届かない並びがある。`ORDER BY name` は辞書順なので `item1, item10, item2` になる。全行が揃ってから並べ替える。 + +```php +final class FileList implements PostQueryInterface +{ + /** @param list $files */ + public function __construct(public readonly array $files) {} + + public static function fromContext(PostQueryContext $context): static + { + $names = []; + foreach ($context->rows as $row) { + $names[(int) $row['id']] = (string) $row['name']; + } + natsort($names); // item1, item2, item10 + + $files = []; + foreach ($names as $id => $name) { + $files[] = new File($id, $name); + } + + return new static($files); + } +} +``` + +業務固有の優先順位(`news → feature → opinion`)も同じ場所に書ける。`usort()` に `['news' => 0, 'feature' => 1, 'opinion' => 2]` を引かせるだけ。テンプレートは並び順を意識しない。 + +## SPL イテレータでフィルタ・制限する + +`PostQueryInterface` と `IteratorAggregate` を一緒に実装すると、「公開済みだけ、最大20件」のような絞り込みをコレクション自身に閉じ込められる。テンプレートで毎回 `{% if %}` を書かなくて済む。 + +```php +final class Posts implements PostQueryInterface, IteratorAggregate +{ + /** @param list $items */ + public function __construct(private readonly array $items) {} + + public static function fromContext(PostQueryContext $context): static + { + $items = []; + foreach ($context->rows as $row) { + $items[] = new Post((int) $row['id'], (string) $row['title'], (bool) $row['is_published']); + } + + return new static($items); + } + + public function getIterator(): Traversable + { + return new LimitIterator( + new CallbackFilterIterator( + new ArrayIterator($this->items), + static fn (Post $p) => $p->isPublished, + ), + 0, + 20, + ); + } +} +``` + +```twig +{% for post in posts %} + {# 下書きはここに来ない。21件目以降も来ない #} + {{ post.title }} +{% endfor %} +``` + +`SplPriorityQueue` を使えば「ピン留めを先頭に、残りは日付順」も同じ場所に書ける。 + +## Null Object — クエリは常に完成したエンティティを返す + +行が見つからないと、`type: 'row'` のクエリは `null` を返す。テンプレートに `{% if profile %}` が増える原因。`fromContext()` で空を判定し、常に完成したエンティティを返す。 + +```php +final class UserProfile implements PostQueryInterface +{ + public function __construct( + public readonly string $displayName, + public readonly string $avatarUrl, + public readonly bool $isGuest, + ) {} + + public static function fromContext(PostQueryContext $context): static + { + $row = $context->rows[0] ?? null; + if ($row === null) { + return new static('Guest', '/img/guest.png', true); // プレースホルダ + } + + return new static((string) $row['name'], (string) $row['avatar_url'], false); + } +} +``` + +```twig +{# 存在チェック不要。avatarUrl は常に値がある #} + +{{ profile.displayName }} +``` + +クエリの戻り値はもう `UserProfile|null` ではなく `UserProfile`。テンプレートは分岐しない。 + +--- + +> 第1部と第2部に共通することが一つある。**テンプレートはプロパティを読むだけ**。判定も計算も並び順も空判定も、クエリ境界で終わっている。違うのは「1行を加工するか、結果セット全体を成形するか」だけ。 diff --git a/docs/tutorial/bdr-patterns.md b/docs/tutorial/bdr-patterns.md new file mode 100644 index 0000000..1dead11 --- /dev/null +++ b/docs/tutorial/bdr-patterns.md @@ -0,0 +1,301 @@ +# BDR Pattern Cookbook + +Ray.MediaQuery has two mechanisms for turning SQL results into objects. Which one you pick changes what you can do. + +| Mechanism | What it receives | Shape | Use for | +|---|---|---|---| +| **`factory:` attribute** | one call per row, columns as args | 1 row → 1 object | per-row enrichment | +| **`PostQueryInterface`** | the whole result set | N rows → 1 object | aggregation, collections, shaping | + +Part 1 uses `factory:`, Part 2 uses `PostQueryInterface`. Confuse them and the code does not run. + +--- + +# Part 1 — `factory:` per-row enrichment + +The factory is called **once per row**, with the SELECT columns passed as positional arguments. The return values line up, one per row. Chapter 7's `ArticleStatsFactory` is this. + +```php +interface ArticleQueryInterface +{ + /** @return list
*/ + #[DbQuery('article_list', factory: ArticleFactory::class)] + public function list(): array; +} +``` + +## The `if` vanishes from templates + +```twig +{# a familiar sight #} +{% if article.status == 'published' and article.publishedAt <= now %} + Live +{% elseif article.status == 'draft' %} + Draft +{% endif %} +``` + +Business logic bleeding into the template. Move it to the factory. + +```php +final class ArticleFactory +{ + public function factory(int $id, string $title, string $status): Article + { + $badge = match($status) { + 'published' => 'badge', + 'draft' => 'badge badge--draft', + default => '', + }; + + return new Article(id: $id, title: $title, status: $status, badge: $badge); + } +} +``` + +```twig +{# template only renders #} +{{ article.status }} +``` + +## Receive a string column as an enum + +The `status` column is a string. Convert it to a PHP enum in the factory for type-safe comparisons. + +```php +public function factory(int $id, string $status): Article +{ + return new Article(id: $id, status: Status::from($status)); +} +``` + +```twig +{# enum comparison, not string comparison #} +{% if article.status == enum('App\\Status::Published') %}...{% endif %} +``` + +A typo throws at `Status::from()` instead of silently failing in a template. No raw strings scattered around. + +## Put display values on the entity + +"Show reading time from body length." "Format the price with commas." Compute it in the factory. + +```php +public function factory(int $id, string $body, int $priceYen): Article +{ + return new Article( + id: $id, + readingMinutes: (int) ceil(mb_strlen($body) / 400), + priceFormatted: number_format($priceYen) . ' JPY', + ); +} +``` + +```twig +{{ article.readingMinutes }} min read +{{ article.priceFormatted }} +``` + +No calculation in the template. The factory is also easy to unit-test in isolation. + +## Inject the current time and current user + +A factory can receive dependencies through its constructor (same as `age` in chapter 8). Build values that are not in any column from injected services. + +```php +final class ArticleFactory +{ + public function __construct( + private readonly CurrentUserInterface $currentUser, + private readonly DateTimeInterface $now, + ) {} + + public function factory(int $id, int $authorId, string $publishedAt): Article + { + return new Article( + id: $id, + isOwn: $authorId === $this->currentUser->id(), + isNew: (new DateTimeImmutable($publishedAt)) > $this->now->modify('-7 days'), + ); + } +} +``` + +```twig +{% if article.isOwn %}Edit{% endif %} +{% if article.isNew %}NEW{% endif %} +``` + +Bind `CurrentUserInterface` in Ray.Di; swap in `FakeCurrentUser` for tests. `DateTimeInterface` is already bound by `MediaQueryModule`. + +--- + +# Part 2 — `PostQueryInterface` shaping the whole result set + +Anything that spans rows — grouping, sorting, filtering, emptiness checks — cannot be done in `factory:`, because `factory:` only ever sees one row at a time. When you need the whole result set, use `PostQueryInterface`. Declare the class as the return type; its `fromContext()` receives `$context->rows` (every row). Chapter 12's `ArticleSearchResult` is this. + +```php +interface ArticleQueryInterface +{ + #[DbQuery('article_with_comments')] + public function withComments(): Articles; // Articles implements PostQueryInterface +} +``` + +## Assemble flat JOIN rows into a parent-child shape + +A JOIN returns flat rows. Grouping comments under each article is tedious in both the template and the controller. Do it in `fromContext()`. + +```sql +SELECT a.id, a.title, c.id AS comment_id, c.body AS comment_body +FROM article a +LEFT JOIN comment c ON c.article_id = a.id +ORDER BY a.id +``` + +```php +final class Articles implements PostQueryInterface +{ + /** @param list
$items */ + public function __construct(public readonly array $items) {} + + public static function fromContext(PostQueryContext $context): static + { + $titles = []; + $comments = []; + foreach ($context->rows as $row) { + $id = $row['id']; + $titles[$id] ??= $row['title']; + if ($row['comment_id'] !== null) { + $comments[$id][] = new Comment((int) $row['comment_id'], (string) $row['comment_body']); + } + } + + $items = []; + foreach ($titles as $id => $title) { + $items[] = new Article((int) $id, (string) $title, $comments[$id] ?? []); + } + + return new static($items); + } +} +``` + +```twig +{% for article in articles.items %} +

{{ article.title }}

+ {% for comment in article.comments %}

{{ comment.body }}

{% endfor %} +{% endfor %} +``` + +One query, nested objects. No N+1, no manual grouping in the controller. + +## Sort in `fromContext()` + +Some orderings are out of `ORDER BY`'s reach. `ORDER BY name` is lexicographic, so it gives `item1, item10, item2`. Reorder once all rows are in hand. + +```php +final class FileList implements PostQueryInterface +{ + /** @param list $files */ + public function __construct(public readonly array $files) {} + + public static function fromContext(PostQueryContext $context): static + { + $names = []; + foreach ($context->rows as $row) { + $names[(int) $row['id']] = (string) $row['name']; + } + natsort($names); // item1, item2, item10 + + $files = []; + foreach ($names as $id => $name) { + $files[] = new File($id, $name); + } + + return new static($files); + } +} +``` + +Business-specific priority (`news → feature → opinion`) lives in the same place — feed `usort()` a `['news' => 0, 'feature' => 1, 'opinion' => 2]` map. The template never thinks about order. + +## Filter and limit with SPL iterators + +Implementing both `PostQueryInterface` and `IteratorAggregate` lets a collection own a rule like "published only, up to 20." No `{% if %}` repeated in the template. + +```php +final class Posts implements PostQueryInterface, IteratorAggregate +{ + /** @param list $items */ + public function __construct(private readonly array $items) {} + + public static function fromContext(PostQueryContext $context): static + { + $items = []; + foreach ($context->rows as $row) { + $items[] = new Post((int) $row['id'], (string) $row['title'], (bool) $row['is_published']); + } + + return new static($items); + } + + public function getIterator(): Traversable + { + return new LimitIterator( + new CallbackFilterIterator( + new ArrayIterator($this->items), + static fn (Post $p) => $p->isPublished, + ), + 0, + 20, + ); + } +} +``` + +```twig +{% for post in posts %} + {# drafts never reach here, and neither does item 21+ #} + {{ post.title }} +{% endfor %} +``` + +`SplPriorityQueue` lets you pin featured posts first, then fall back to date order — same place. + +## Null Object — the query always returns a complete entity + +When no row is found, a `type: 'row'` query returns `null` — the source of every `{% if profile %}` in a template. Check for emptiness in `fromContext()` and always return a complete entity. + +```php +final class UserProfile implements PostQueryInterface +{ + public function __construct( + public readonly string $displayName, + public readonly string $avatarUrl, + public readonly bool $isGuest, + ) {} + + public static function fromContext(PostQueryContext $context): static + { + $row = $context->rows[0] ?? null; + if ($row === null) { + return new static('Guest', '/img/guest.png', true); // placeholder + } + + return new static((string) $row['name'], (string) $row['avatar_url'], false); + } +} +``` + +```twig +{# no existence check; avatarUrl always has a value #} + +{{ profile.displayName }} +``` + +The return type is no longer `UserProfile|null` but `UserProfile`. The template never branches. + +--- + +> One thing Part 1 and Part 2 share: **the template only reads properties**. Every decision, calculation, ordering, and emptiness check is finished at the query boundary. The only difference is whether you enrich one row or shape the whole result set. diff --git a/docs/tutorial/src/Blog/AuthorProfile.php b/docs/tutorial/src/Blog/AuthorProfile.php new file mode 100644 index 0000000..3dbc112 --- /dev/null +++ b/docs/tutorial/src/Blog/AuthorProfile.php @@ -0,0 +1,16 @@ +diff($this->now)->y; + + return new AuthorProfile( + id: $id, + name: $name, + birthDate: $birthDate, + age: $age, + ); + } +} diff --git a/docs/tutorial/src/Blog/AuthorQueryInterface.php b/docs/tutorial/src/Blog/AuthorQueryInterface.php new file mode 100644 index 0000000..c2c3d68 --- /dev/null +++ b/docs/tutorial/src/Blog/AuthorQueryInterface.php @@ -0,0 +1,13 @@ +install(new MediaQueryModule($queries, [new DbQueryConfig($this->sqlDir)])); $this->install(new AuraSqlModule($this->dsn)); @@ -66,7 +67,7 @@ protected function configure(): void ); $firstStatus = $first->values['status'] ?? null; assert(is_string($firstStatus)); -printf("inserted id=%s, status=%s\n", (string) $first->id, $firstStatus); +printf("inserted id=%s, status=%s\n", $first->id, $firstStatus); $second = $articleQuery->add( title: 'Second Post', @@ -76,7 +77,7 @@ protected function configure(): void publishedAt: new DateTimeImmutable('2026-04-02 10:00:00'), createdAt: new DateTimeImmutable('2026-04-02 10:00:00'), ); -printf("inserted id=%s\n\n", (string) $second->id); +printf("inserted id=%s\n\n", $second->id); echo "=== Ch.1 / Ch.4 / Ch.5: SELECT list as Article entities ===\n"; $articles = $articleQuery->list(); @@ -86,28 +87,35 @@ protected function configure(): void echo "=== Ch.2 / Ch.6: SELECT row + ArticleId (ToScalarInterface) ===\n"; $article = $articleQuery->item(new ArticleId(1)); assert($article !== null); -printf("item(ArticleId(1)) -> '%s' published_at=%s\n\n", $article->title, (string) $article->publishedAt); +printf("item(ArticleId(1)) -> '%s' published_at=%s\n\n", $article->title, $article->publishedAt); echo "=== Ch.7 / Ch.8: factory with DI (ArticleStats) + Comment hydration ===\n"; $commentQuery->add(1, 'Great post!', new DateTimeImmutable('2026-04-01 12:00:00')); $commentQuery->add(1, 'Thanks for sharing.', new DateTimeImmutable('2026-04-01 13:00:00')); $stats = $articleQuery->stats(new ArticleId(1)); -printf("stats: title='%s' commentCount=%d published=%s\n", $stats->title, $stats->commentCount, $stats->published ? 'true' : 'false'); -printf("excerpt='%s'\n", $stats->excerpt); +printf("commentCount=%d, excerpt='%s'\n", $stats->commentCount, $stats->excerpt); $comments = $commentQuery->listFor(1); printf("comments=%d, first body='%s' (id=%d)\n\n", count($comments), $comments[0]->body, $comments[0]->id); +echo "=== Ch.8 / BDR: age from birth_date ===\n"; +$pdo->perform('INSERT INTO author (name, birth_date) VALUES (?, ?)', ['Alice', '1990-06-15']); +/** @var AuthorQueryInterface $authorQuery */ +$authorQuery = $injector->getInstance(AuthorQueryInterface::class); +$profile = $authorQuery->profile(1); +assert($profile !== null); +printf("name=%s birth_date=%s age=%d\n\n", $profile->name, $profile->birthDate, $profile->age); + echo "=== Ch.9: AffectedRows ===\n"; $updated = $articleQuery->update(new ArticleId(1), 'Hello, Ray.MediaQuery (edited)', 'Updated body.'); -printf("update affected=%d isAffected=%s\n", $updated->count, $updated->isAffected() ? 'true' : 'false'); +printf("updated count=%d, isAffected=%s\n", $updated->count, $updated->isAffected() ? 'yes' : 'no'); $deleted = $articleQuery->delete(new ArticleId(2)); -printf("delete affected=%d isAffected=%s\n\n", $deleted->count, $deleted->isAffected() ? 'true' : 'false'); +printf("deleted count=%d\n\n", $deleted->count); echo "=== Ch.11: Pager (Pages
) ===\n"; for ($i = 3; $i <= 32; $i++) { $articleQuery->add( - title: "Post #{$i}", - body: "Body for post {$i}.", + title: "Post #$i", + body: "Body for post $i.", authorName: 'Carol', status: 'published', publishedAt: new DateTimeImmutable('2026-04-03 00:00:00'), @@ -120,8 +128,9 @@ protected function configure(): void assert(is_array($page1->data)); $firstPageArticle = $page1->data[0] ?? null; assert($firstPageArticle instanceof Article); -printf("total items=%d, current=%d, hasNext=%s\n", count($pages), $page1->current, $page1->hasNext ? 'true' : 'false'); -printf("page 1 has %d items, first title='%s'\n\n", count($page1->data), $firstPageArticle->title); +printf("total items=%d\n", count($pages)); +printf("page 1 has %d items, hasNext=%s\n", count($page1->data), $page1->hasNext ? 'yes' : 'no'); +echo $firstPageArticle->title, "\n\n"; echo "=== Ch.11 / Ray.MediaQuery 1.1: Pager + factory hydration ===\n"; $statsPages = $articleQuery->statsPaginated(); @@ -135,7 +144,7 @@ protected function configure(): void echo "=== Ch.12: custom PostQueryInterface (ArticleSearchResult) ===\n"; $result = $articleQuery->search('%Post%'); printf("matched=%d, sql contains 'LIKE'=%s\n", $result->matched, str_contains($result->sql, 'LIKE') ? 'yes' : 'no'); -printf("first hit: id=%d title='%s'\n\n", $result->rows[0]->id, $result->rows[0]->title); +echo "First hit: ", $result->rows[0]->title, "\n\n"; echo "=== Appendix: Multi-statement DML + SELECT PostQuery ===\n"; $created = $articleQuery->createAndGet( diff --git a/docs/tutorial/src/schema.sql b/docs/tutorial/src/schema.sql index 10a4220..b406b65 100644 --- a/docs/tutorial/src/schema.sql +++ b/docs/tutorial/src/schema.sql @@ -14,3 +14,9 @@ CREATE TABLE IF NOT EXISTS comment ( body TEXT NOT NULL, posted_at TEXT NOT NULL ); + +CREATE TABLE IF NOT EXISTS author ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + birth_date TEXT NOT NULL +); diff --git a/docs/tutorial/src/sql/author_profile.sql b/docs/tutorial/src/sql/author_profile.sql new file mode 100644 index 0000000..42b56ac --- /dev/null +++ b/docs/tutorial/src/sql/author_profile.sql @@ -0,0 +1,6 @@ +SELECT + id, + name, + birth_date +FROM author +WHERE id = :id; From 944ec7e00519310207fe321bf9d9044ae8a7af3f Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 2 Sep 2026 09:38:51 +0900 Subject: [PATCH 2/4] Remove duplicated paragraph in tutorial BDR focus section Co-Authored-By: Claude Opus 4.8 --- docs/tutorial/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/tutorial/README.md b/docs/tutorial/README.md index aea565f..46b5ea5 100644 --- a/docs/tutorial/README.md +++ b/docs/tutorial/README.md @@ -951,8 +951,6 @@ $this->bind(MarkdownExcerpter::class); ### BDR focus: why DI is necessary -`age` is not a database column. It is computed from `birth_date` and the current time — the current time must be injected as `DateTimeInterface`. - `age` is not a database column. It requires two inputs: the stored `birth_date` and the current time. The current time must come from outside the factory — injected as `DateTimeInterface`. Add an `author` table to `mywork/schema.sql`: From 3d474ab9a6efd6ca235eda276615010e05a1296e Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 2 Sep 2026 10:22:05 +0900 Subject: [PATCH 3/4] Address CodeRabbit review on tutorial BDR docs - Cookbook current-time example: convert the injected clock with DateTimeImmutable::createFromInterface() before modify() so a mutable DateTime override cannot mutate the shared factory instance (EN + JA). - Remove the duplicated DI-requirement paragraph in README.ja.md. - Pin the clock in the tutorial Module so the BDR age output is a reproducible 35; document the pin in both READMEs. Co-Authored-By: Claude Opus 4.8 --- docs/tutorial/README.ja.md | 10 ++++++---- docs/tutorial/README.md | 8 ++++++-- docs/tutorial/bdr-patterns.ja.md | 2 +- docs/tutorial/bdr-patterns.md | 2 +- docs/tutorial/src/run.php | 4 ++++ 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/tutorial/README.ja.md b/docs/tutorial/README.ja.md index 325e0bf..571ea4f 100644 --- a/docs/tutorial/README.ja.md +++ b/docs/tutorial/README.ja.md @@ -953,8 +953,6 @@ $this->bind(MarkdownExcerpter::class); ### BDR の核心: DI が不可欠な例 -`age`(年齢)はデータベースのカラムではない。`birth_date` と現在時刻から計算する — 現在時刻はファクトリの外から `DateTimeInterface` として注入するしかない。 - `age`(年齢)はデータベースのカラムではない。`birth_date` と「現在時刻」の2つから計算する。現在時刻はファクトリの外から注入するしかない — `DateTimeInterface` として DI で受け取る。 `mywork/schema.sql` に `author` テーブルを追加: @@ -1042,7 +1040,11 @@ interface AuthorQueryInterface $this->bind(MarkdownExcerpter::class); ``` -`DateTimeInterface` は `MediaQueryModule` 内部で既に `DateTimeImmutable` に bind されているため、追加の bind は不要。 +`DateTimeInterface` は `MediaQueryModule` 内部で既に `DateTimeImmutable` に bind されており、現在時刻に解決される。このサンプルの `age` を再現可能にするため、チュートリアルでは Module でクロックを固定する: + +```php +$this->bind(DateTimeInterface::class)->toInstance(new DateTimeImmutable('2026-06-06')); +``` `run.php` でデータを挿入して呼び出す: @@ -1061,7 +1063,7 @@ printf("name=%s birth_date=%s age=%d\n", $profile->name, $profile->birthDate, $p name=Alice birth_date=1990-06-15 age=35 ``` -> `age` はクエリ境界でファクトリが `birth_date` と `DateTimeInterface $now` から計算する。35 という値は `birth_date = '1990-06-15'` と実行日 2026-06-06 の組み合わせで、毎年変わる。`MediaQueryModule` が `DateTimeInterface` を `DateTimeImmutable` に bind しているため追加設定は不要(注入のたびに `new DateTimeImmutable()` が生成され、常に現在時刻が渡る)。テストでは固定インスタンスで上書き bind すれば `age` を決定的にできる。 +> `age` はクエリ境界でファクトリが `birth_date` と注入された `DateTimeInterface $now` から計算する。Module でクロックを `2026-06-06` に固定しているため `age` は再現可能な `35`(その年の6月15日の誕生日をまだ迎えていない)。固定を外せば `MediaQueryModule` の既定の `DateTimeImmutable` bind が実際の現在時刻に解決され、`age` は今日の日付を反映する。 コントローラーとテンプレートは `$profile->age` と書くだけで値が手に入る — クエリ境界の外での計算はゼロ。これが BDR の本質: **エンティティは受け取った時点で完成している**。 diff --git a/docs/tutorial/README.md b/docs/tutorial/README.md index 46b5ea5..9ec3a03 100644 --- a/docs/tutorial/README.md +++ b/docs/tutorial/README.md @@ -1038,7 +1038,11 @@ Add `AuthorQueryInterface::class` to `Queries::fromClasses()`, and add `Markdown $this->bind(MarkdownExcerpter::class); ``` -`DateTimeInterface` is already bound to `DateTimeImmutable` inside `MediaQueryModule` — no extra binding is needed. +`DateTimeInterface` is already bound to `DateTimeImmutable` inside `MediaQueryModule`, so it resolves to the current time. To keep this sample's `age` reproducible, the tutorial pins the clock in the Module: + +```php +$this->bind(DateTimeInterface::class)->toInstance(new DateTimeImmutable('2026-06-06')); +``` Seed an author and call `profile()` in `run.php`: @@ -1057,7 +1061,7 @@ printf("name=%s birth_date=%s age=%d\n", $profile->name, $profile->birthDate, $p name=Alice birth_date=1990-06-15 age=35 ``` -> `age` is computed at the query boundary from `birth_date` and `DateTimeInterface $now`. The value above is based on `birth_date = '1990-06-15'` and a run date of 2026-06-06; it advances each year. `MediaQueryModule` already binds `DateTimeInterface` to `DateTimeImmutable` (resolved at inject time, not compile time). In tests, override that binding with a fixed instance to make `age` deterministic. +> `age` is computed at the query boundary from `birth_date` and the injected `DateTimeInterface $now`. Because the Module pins the clock to `2026-06-06`, `age` is a reproducible `35` (the June 15 birthday has not yet passed that year). Remove the pin and `MediaQueryModule`'s default `DateTimeImmutable` binding resolves to the real current time, so `age` tracks today's date. The controller and template write `$profile->age` and receive a ready value — no calculation outside the query boundary. This is BDR: the entity arrives complete. diff --git a/docs/tutorial/bdr-patterns.ja.md b/docs/tutorial/bdr-patterns.ja.md index bde2a5b..52191f0 100644 --- a/docs/tutorial/bdr-patterns.ja.md +++ b/docs/tutorial/bdr-patterns.ja.md @@ -115,7 +115,7 @@ final class ArticleFactory return new Article( id: $id, isOwn: $authorId === $this->currentUser->id(), - isNew: (new DateTimeImmutable($publishedAt)) > $this->now->modify('-7 days'), + isNew: (new DateTimeImmutable($publishedAt)) > DateTimeImmutable::createFromInterface($this->now)->modify('-7 days'), ); } } diff --git a/docs/tutorial/bdr-patterns.md b/docs/tutorial/bdr-patterns.md index 1dead11..827899a 100644 --- a/docs/tutorial/bdr-patterns.md +++ b/docs/tutorial/bdr-patterns.md @@ -115,7 +115,7 @@ final class ArticleFactory return new Article( id: $id, isOwn: $authorId === $this->currentUser->id(), - isNew: (new DateTimeImmutable($publishedAt)) > $this->now->modify('-7 days'), + isNew: (new DateTimeImmutable($publishedAt)) > DateTimeImmutable::createFromInterface($this->now)->modify('-7 days'), ); } } diff --git a/docs/tutorial/src/run.php b/docs/tutorial/src/run.php index aa92aa6..1ffd41f 100644 --- a/docs/tutorial/src/run.php +++ b/docs/tutorial/src/run.php @@ -7,6 +7,7 @@ use Aura\Sql\ExtendedPdoInterface; use Composer\Autoload\ClassLoader; use DateTimeImmutable; +use DateTimeInterface; use Ray\AuraSqlModule\AuraSqlModule; use Ray\AuraSqlModule\Pagerfanta\Page; use Ray\Di\AbstractModule; @@ -40,6 +41,9 @@ protected function configure(): void $this->install(new MediaQueryModule($queries, [new DbQueryConfig($this->sqlDir)])); $this->install(new AuraSqlModule($this->dsn)); $this->bind(MarkdownExcerpter::class); + // Pin the clock so the BDR `age` output is reproducible. In production, + // MediaQueryModule's DateTimeImmutable binding resolves to the real time. + $this->bind(DateTimeInterface::class)->toInstance(new DateTimeImmutable('2026-06-06')); } }); From 926fd44a395ac00345e01deeed9a58626b42cd5b Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 2 Sep 2026 10:48:19 +0900 Subject: [PATCH 4/4] Address review on tutorial BDR docs - Publish the cookbook as a real site page: add Jekyll front matter with permalinks /tutorial/bdr-patterns/ and /tutorial/bdr-patterns/ja/. Without it Jekyll copied the files verbatim, so the EN link served raw Markdown and the JA link 404'd (/tutorial/ja/bdr-patterns.ja.md does not exist). - Drop the copy-pasted "add MarkdownExcerpter to the Module" instruction from the BDR focus section; it belongs to Step 3 and is unrelated to the author example. AuthorProfileFactory needs no binding. - Move the BDR focus section after chapter 8's Explanation so it no longer interrupts the Step 3 -> Step 4 sequence, and relabel its expected output as integrated run.php (the code lives in run.php, not a standalone snippet). - List the Author* classes and author_profile.sql in the completed directory tree; add the Exception/ entry that only the Japanese tree had. - Add the null assertion the README snippet was missing (profile() returns AuthorProfile|null). - Cookbook: warn that PDO::FETCH_FUNC maps columns by position, not by name; add EN/JA and back-to-tutorial links; demote Part 1/Part 2 from H1 to H2 so the page has a single H1. Co-Authored-By: Claude Opus 5 --- docs/tutorial/README.ja.md | 235 +++++++++++++++--------------- docs/tutorial/README.md | 239 ++++++++++++++++--------------- docs/tutorial/bdr-patterns.ja.md | 32 +++-- docs/tutorial/bdr-patterns.md | 32 +++-- 4 files changed, 283 insertions(+), 255 deletions(-) diff --git a/docs/tutorial/README.ja.md b/docs/tutorial/README.ja.md index 571ea4f..3d2335e 100644 --- a/docs/tutorial/README.ja.md +++ b/docs/tutorial/README.ja.md @@ -89,6 +89,9 @@ docs/tutorial/src/ │ ├── ArticleStats.php │ ├── ArticleStatsFactory.php # DI ファクトリ │ ├── MarkdownExcerpter.php # ファクトリへの注入対象 +│ ├── AuthorProfile.php # `age` はカラムではない +│ ├── AuthorProfileFactory.php # クロックを注入する DI ファクトリ +│ ├── AuthorQueryInterface.php │ ├── ArticleSearchResult.php # SELECT 用 PostQueryInterface │ ├── CreatedArticle.php # DML + SELECT 用 PostQueryInterface │ └── Exception/ @@ -104,6 +107,7 @@ docs/tutorial/src/ ├── article_search.sql ├── article_stats.sql ├── article_stats_paginated.sql + ├── author_profile.sql ├── comment_add.sql └── comment_list.sql ``` @@ -951,122 +955,6 @@ final class ArticleStatsFactory $this->bind(MarkdownExcerpter::class); ``` -### BDR の核心: DI が不可欠な例 - -`age`(年齢)はデータベースのカラムではない。`birth_date` と「現在時刻」の2つから計算する。現在時刻はファクトリの外から注入するしかない — `DateTimeInterface` として DI で受け取る。 - -`mywork/schema.sql` に `author` テーブルを追加: - -```sql -CREATE TABLE IF NOT EXISTS author ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - birth_date TEXT NOT NULL -); -``` - -`sql/author_profile.sql`: - -```sql -SELECT - id, - name, - birth_date -FROM author -WHERE id = :id; -``` - -`Blog/AuthorProfile.php`: - -```php -final class AuthorProfile -{ - public function __construct( - public readonly int $id, - public readonly string $name, - public readonly string $birthDate, - public readonly int $age, // カラムではない — ファクトリが計算する - ) {} -} -``` - -`Blog/AuthorProfileFactory.php`: - -```php -use DateTimeImmutable; -use DateTimeInterface; - -final class AuthorProfileFactory -{ - public function __construct( - private readonly DateTimeInterface $now, - ) {} - - public function factory(int $id, string $name, string $birthDate): AuthorProfile - { - $age = (new DateTimeImmutable($birthDate))->diff($this->now)->y; - - return new AuthorProfile( - id: $id, - name: $name, - birthDate: $birthDate, - age: $age, - ); - } -} -``` - -`Blog/AuthorQueryInterface.php`: - -```php -bind(MarkdownExcerpter::class); -``` - -`DateTimeInterface` は `MediaQueryModule` 内部で既に `DateTimeImmutable` に bind されており、現在時刻に解決される。このサンプルの `age` を再現可能にするため、チュートリアルでは Module でクロックを固定する: - -```php -$this->bind(DateTimeInterface::class)->toInstance(new DateTimeImmutable('2026-06-06')); -``` - -`run.php` でデータを挿入して呼び出す: - -```php -$pdo->perform('INSERT INTO author (name, birth_date) VALUES (?, ?)', ['Alice', '1990-06-15']); - -/** @var AuthorQueryInterface $authorQuery */ -$authorQuery = $injector->getInstance(AuthorQueryInterface::class); -$profile = $authorQuery->profile(1); -printf("name=%s birth_date=%s age=%d\n", $profile->name, $profile->birthDate, $profile->age); -``` - -### 期待出力 (第8章 / BDR フォーカス / 単独実行) - -```text -name=Alice birth_date=1990-06-15 age=35 -``` - -> `age` はクエリ境界でファクトリが `birth_date` と注入された `DateTimeInterface $now` から計算する。Module でクロックを `2026-06-06` に固定しているため `age` は再現可能な `35`(その年の6月15日の誕生日をまだ迎えていない)。固定を外せば `MediaQueryModule` の既定の `DateTimeImmutable` bind が実際の現在時刻に解決され、`age` は今日の日付を反映する。 - -コントローラーとテンプレートは `$profile->age` と書くだけで値が手に入る — クエリ境界の外での計算はゼロ。これが BDR の本質: **エンティティは受け取った時点で完成している**。 - ### Step 4. Comment 関連のファイルを足す stats を意味あるものにするためにコメントが要る。ここで Comment Entity と CommentQueryInterface を作って、`add()` / `listFor()` の2メソッドで運用する。`listFor()` は第1章の `list()` と同じ `array` 型を返すので、Entity hydration の復習にもなる。 @@ -1182,6 +1070,119 @@ comments=2, first body='Great post!' (id=1) - これが Ray.MediaQuery を「単なるクエリマッパー」と区別する点 — **SQL の結果に対してドメイン処理を効率良く適用できる**。 - Business Domain Repository (BDR) パターンは [BDR_PATTERN-ja.md](https://github.com/ray-di/Ray.MediaQuery/blob/1.x/BDR_PATTERN-ja.md) で詳述。 +### BDR の核心: DI が不可欠な例 + +`age`(年齢)はデータベースのカラムではない。`birth_date` と「現在時刻」の2つから計算する。現在時刻はファクトリの外から注入するしかない — `DateTimeInterface` として DI で受け取る。 + +`mywork/schema.sql` に `author` テーブルを追加: + +```sql +CREATE TABLE IF NOT EXISTS author ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + birth_date TEXT NOT NULL +); +``` + +`sql/author_profile.sql`: + +```sql +SELECT + id, + name, + birth_date +FROM author +WHERE id = :id; +``` + +`Blog/AuthorProfile.php`: + +```php +final class AuthorProfile +{ + public function __construct( + public readonly int $id, + public readonly string $name, + public readonly string $birthDate, + public readonly int $age, // カラムではない — ファクトリが計算する + ) {} +} +``` + +`Blog/AuthorProfileFactory.php`: + +```php +use DateTimeImmutable; +use DateTimeInterface; + +final class AuthorProfileFactory +{ + public function __construct( + private readonly DateTimeInterface $now, + ) {} + + public function factory(int $id, string $name, string $birthDate): AuthorProfile + { + $age = (new DateTimeImmutable($birthDate))->diff($this->now)->y; + + return new AuthorProfile( + id: $id, + name: $name, + birthDate: $birthDate, + age: $age, + ); + } +} +``` + +`Blog/AuthorQueryInterface.php`: + +```php +bind(DateTimeInterface::class)->toInstance(new DateTimeImmutable('2026-06-06')); +``` + +`run.php` でデータを挿入して呼び出す: + +```php +$pdo->perform('INSERT INTO author (name, birth_date) VALUES (?, ?)', ['Alice', '1990-06-15']); + +/** @var AuthorQueryInterface $authorQuery */ +$authorQuery = $injector->getInstance(AuthorQueryInterface::class); +$profile = $authorQuery->profile(1); +assert($profile !== null); +printf("name=%s birth_date=%s age=%d\n", $profile->name, $profile->birthDate, $profile->age); +``` + +### 期待出力 (第8章 / BDR フォーカス / 統合 run.php) + +```text +name=Alice birth_date=1990-06-15 age=35 +``` + +> `age` はクエリ境界でファクトリが `birth_date` と注入された `DateTimeInterface $now` から計算する。Module でクロックを `2026-06-06` に固定しているため `age` は再現可能な `35`(その年の6月15日の誕生日をまだ迎えていない)。固定を外せば `MediaQueryModule` の既定の `DateTimeImmutable` bind が実際の現在時刻に解決され、`age` は今日の日付を反映する。 + +コントローラーとテンプレートは `$profile->age` と書くだけで値が手に入る — クエリ境界の外での計算はゼロ。これが BDR の本質: **エンティティは受け取った時点で完成している**。 + --- ## 第9章: UPDATE / DELETE と影響行数 @@ -1865,7 +1866,7 @@ Ray.MediaQuery は、その Read 側を Query-first に分割する。`UserRepos ### 次に読むもの -- [BDR パターン集](bdr-patterns.ja.md) — 行ごとの加工(`factory:`)と結果セット全体の成形(`PostQueryInterface`): バッジ・enum・JOINグルーピング・ソート・SPLイテレータ・Null Object +- [BDR パターン集](https://ray-di.github.io/Ray.MediaQuery/tutorial/bdr-patterns/ja/) — 行ごとの加工(`factory:`)と結果セット全体の成形(`PostQueryInterface`): バッジ・enum・JOINグルーピング・ソート・SPLイテレータ・Null Object - [BDR Pattern Guide 日本語版](https://github.com/ray-di/Ray.MediaQuery/blob/1.x/BDR_PATTERN-ja.md) — ファクトリパターンとドメインオブジェクトの設計 - [Manual](https://ray-di.github.io/Ray.MediaQuery/reference/) — マニュアル (`#[Input]` Object Flattening, `SqlQueryInterface` 直接実行などの応用) - [llms-full.txt](../llms-full.txt) — AI エージェント向けの圧縮リファレンス diff --git a/docs/tutorial/README.md b/docs/tutorial/README.md index 9ec3a03..fce2709 100644 --- a/docs/tutorial/README.md +++ b/docs/tutorial/README.md @@ -89,8 +89,13 @@ docs/tutorial/src/ | |-- ArticleStats.php | |-- ArticleStatsFactory.php # DI factory | |-- MarkdownExcerpter.php # Injected into the factory +| |-- AuthorProfile.php # `age` is not a column +| |-- AuthorProfileFactory.php # DI factory with an injected clock +| |-- AuthorQueryInterface.php | |-- ArticleSearchResult.php # SELECT PostQueryInterface -| `-- CreatedArticle.php # DML + SELECT PostQueryInterface +| |-- CreatedArticle.php # DML + SELECT PostQueryInterface +| `-- Exception/ +| `-- UnexpectedRowException.php # Domain exception thrown by result classes `-- sql/ |-- article_add.sql |-- article_create_and_get.sql @@ -102,6 +107,7 @@ docs/tutorial/src/ |-- article_search.sql |-- article_stats.sql |-- article_stats_paginated.sql + |-- author_profile.sql |-- comment_add.sql `-- comment_list.sql ``` @@ -949,122 +955,6 @@ Add this to the Module's `configure()` method in `run.php`. $this->bind(MarkdownExcerpter::class); ``` -### BDR focus: why DI is necessary - -`age` is not a database column. It requires two inputs: the stored `birth_date` and the current time. The current time must come from outside the factory — injected as `DateTimeInterface`. - -Add an `author` table to `mywork/schema.sql`: - -```sql -CREATE TABLE IF NOT EXISTS author ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - birth_date TEXT NOT NULL -); -``` - -`sql/author_profile.sql`: - -```sql -SELECT - id, - name, - birth_date -FROM author -WHERE id = :id; -``` - -`Blog/AuthorProfile.php`: - -```php -final class AuthorProfile -{ - public function __construct( - public readonly int $id, - public readonly string $name, - public readonly string $birthDate, - public readonly int $age, // not a column — computed by the factory - ) {} -} -``` - -`Blog/AuthorProfileFactory.php`: - -```php -use DateTimeImmutable; -use DateTimeInterface; - -final class AuthorProfileFactory -{ - public function __construct( - private readonly DateTimeInterface $now, - ) {} - - public function factory(int $id, string $name, string $birthDate): AuthorProfile - { - $age = (new DateTimeImmutable($birthDate))->diff($this->now)->y; - - return new AuthorProfile( - id: $id, - name: $name, - birthDate: $birthDate, - age: $age, - ); - } -} -``` - -`Blog/AuthorQueryInterface.php`: - -```php -bind(MarkdownExcerpter::class); -``` - -`DateTimeInterface` is already bound to `DateTimeImmutable` inside `MediaQueryModule`, so it resolves to the current time. To keep this sample's `age` reproducible, the tutorial pins the clock in the Module: - -```php -$this->bind(DateTimeInterface::class)->toInstance(new DateTimeImmutable('2026-06-06')); -``` - -Seed an author and call `profile()` in `run.php`: - -```php -$pdo->perform('INSERT INTO author (name, birth_date) VALUES (?, ?)', ['Alice', '1990-06-15']); - -/** @var AuthorQueryInterface $authorQuery */ -$authorQuery = $injector->getInstance(AuthorQueryInterface::class); -$profile = $authorQuery->profile(1); -printf("name=%s birth_date=%s age=%d\n", $profile->name, $profile->birthDate, $profile->age); -``` - -### Expected Output (chapter 8 / BDR focus / standalone) - -```text -name=Alice birth_date=1990-06-15 age=35 -``` - -> `age` is computed at the query boundary from `birth_date` and the injected `DateTimeInterface $now`. Because the Module pins the clock to `2026-06-06`, `age` is a reproducible `35` (the June 15 birthday has not yet passed that year). Remove the pin and `MediaQueryModule`'s default `DateTimeImmutable` binding resolves to the real current time, so `age` tracks today's date. - -The controller and template write `$profile->age` and receive a ready value — no calculation outside the query boundary. This is BDR: the entity arrives complete. - ### Step 4. Add comment-related files To make `stats()` meaningful, add comments. This also revisits Entity hydration through an `array` return type. @@ -1180,6 +1070,119 @@ comments=2, first body='Great post!' (id=1) - 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). +### BDR focus: why DI is necessary + +`age` is not a database column. It requires two inputs: the stored `birth_date` and the current time. The current time must come from outside the factory — injected as `DateTimeInterface`. + +Add an `author` table to `mywork/schema.sql`: + +```sql +CREATE TABLE IF NOT EXISTS author ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + birth_date TEXT NOT NULL +); +``` + +`sql/author_profile.sql`: + +```sql +SELECT + id, + name, + birth_date +FROM author +WHERE id = :id; +``` + +`Blog/AuthorProfile.php`: + +```php +final class AuthorProfile +{ + public function __construct( + public readonly int $id, + public readonly string $name, + public readonly string $birthDate, + public readonly int $age, // not a column — computed by the factory + ) {} +} +``` + +`Blog/AuthorProfileFactory.php`: + +```php +use DateTimeImmutable; +use DateTimeInterface; + +final class AuthorProfileFactory +{ + public function __construct( + private readonly DateTimeInterface $now, + ) {} + + public function factory(int $id, string $name, string $birthDate): AuthorProfile + { + $age = (new DateTimeImmutable($birthDate))->diff($this->now)->y; + + return new AuthorProfile( + id: $id, + name: $name, + birthDate: $birthDate, + age: $age, + ); + } +} +``` + +`Blog/AuthorQueryInterface.php`: + +```php +bind(DateTimeInterface::class)->toInstance(new DateTimeImmutable('2026-06-06')); +``` + +Seed an author and call `profile()` in `run.php`: + +```php +$pdo->perform('INSERT INTO author (name, birth_date) VALUES (?, ?)', ['Alice', '1990-06-15']); + +/** @var AuthorQueryInterface $authorQuery */ +$authorQuery = $injector->getInstance(AuthorQueryInterface::class); +$profile = $authorQuery->profile(1); +assert($profile !== null); +printf("name=%s birth_date=%s age=%d\n", $profile->name, $profile->birthDate, $profile->age); +``` + +### Expected Output (chapter 8 / BDR focus / integrated run.php) + +```text +name=Alice birth_date=1990-06-15 age=35 +``` + +> `age` is computed at the query boundary from `birth_date` and the injected `DateTimeInterface $now`. Because the Module pins the clock to `2026-06-06`, `age` is a reproducible `35` (the June 15 birthday has not yet passed that year). Remove the pin and `MediaQueryModule`'s default `DateTimeImmutable` binding resolves to the real current time, so `age` tracks today's date. + +The controller and template write `$profile->age` and receive a ready value — no calculation outside the query boundary. This is BDR: the entity arrives complete. + --- ## Chapter 9: UPDATE / DELETE and Affected Row Counts @@ -1861,7 +1864,7 @@ This hands-on tutorial focuses on understanding application Query contracts buil ### Next Reading -- [BDR Pattern Cookbook](bdr-patterns.md) - per-row enrichment (`factory:`) vs. whole-result-set shaping (`PostQueryInterface`): badges, enums, JOIN grouping, sorting, SPL iterators, Null Object +- [BDR Pattern Cookbook](https://ray-di.github.io/Ray.MediaQuery/tutorial/bdr-patterns/) - per-row enrichment (`factory:`) vs. whole-result-set shaping (`PostQueryInterface`): badges, enums, JOIN grouping, sorting, SPL iterators, Null Object - [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 diff --git a/docs/tutorial/bdr-patterns.ja.md b/docs/tutorial/bdr-patterns.ja.md index 52191f0..fd517cd 100644 --- a/docs/tutorial/bdr-patterns.ja.md +++ b/docs/tutorial/bdr-patterns.ja.md @@ -1,5 +1,15 @@ +--- +layout: default +title: BDR パターン集 +description: 行ごとの加工(factory 属性)と結果セット全体の成形(PostQueryInterface)の使い分け。バッジ、enum、JOIN グルーピング、ソート、SPL イテレータ、Null Object。 +lang: ja +permalink: /tutorial/bdr-patterns/ja/ +--- + # BDR パターン集 +[English]({{ '/tutorial/bdr-patterns/' | relative_url }}) | [ハンズオンチュートリアル]({{ '/tutorial/ja/' | relative_url }}) + Ray.MediaQuery には、SQL の結果をオブジェクトに変える機構が2つある。どちらを使うかで「できること」が変わる。 | 機構 | 渡されるもの | 形 | 用途 | @@ -11,10 +21,12 @@ Ray.MediaQuery には、SQL の結果をオブジェクトに変える機構が2 --- -# 第1部 — `factory:` 行ごとの加工 +## 第1部 — `factory:` 行ごとの加工 ファクトリは**1行につき1回**呼ばれ、SELECT のカラムが順番に引数で渡る。戻り値が行の数だけ並ぶ。チュートリアル第7章の `ArticleStatsFactory` がこれ。 +> 対応づけは**名前ではなく位置**(`PDO::FETCH_FUNC`)。ファクトリのシグネチャは SELECT のカラム列と順序・個数を 1:1 で合わせる必要がある。引数を減らしても「その名前のカラムが選ばれる」わけではなく、先頭から順に詰められるだけ。以下の各例は、SELECT がそのファクトリの宣言どおりのカラムを返す前提。 + ```php interface ArticleQueryInterface { @@ -24,7 +36,7 @@ interface ArticleQueryInterface } ``` -## テンプレートの `if` が消える +### テンプレートの `if` が消える ```twig {# よくある光景 #} @@ -58,7 +70,7 @@ final class ArticleFactory {{ article.status }} ``` -## 文字列カラムを enum で受け取る +### 文字列カラムを enum で受け取る DB の `status` は文字列。ファクトリで PHP の enum に変換すれば、型安全な比較になる。 @@ -76,7 +88,7 @@ public function factory(int $id, string $status): Article タイプミスは `Status::from()` の時点で例外になる。テンプレートに生の文字列が散らばらない。 -## 表示用の値をエンティティに乗せる +### 表示用の値をエンティティに乗せる 「本文の文字数から読了時間を出したい」「価格をカンマ区切りで表示したい」。ファクトリで計算して乗せる。 @@ -98,7 +110,7 @@ public function factory(int $id, string $body, int $priceYen): Article テンプレートに計算式がない。テストもファクトリ単体で書ける。 -## 現在時刻・現在ユーザーを注入する +### 現在時刻・現在ユーザーを注入する ファクトリはコンストラクタで DI を受け取れる(第8章の `age` と同じ)。SQL のカラムにない値を、外から注入した依存で組み立てる。 @@ -130,7 +142,7 @@ final class ArticleFactory --- -# 第2部 — `PostQueryInterface` 結果セット全体の成形 +## 第2部 — `PostQueryInterface` 結果セット全体の成形 行をまたぐ処理(グルーピング、ソート、絞り込み、空判定)は `factory:` ではできない。`factory:` は1行ずつしか見ないからだ。結果セット全体が要るときは `PostQueryInterface` を使う。クラスは戻り値型として宣言し、`fromContext()` に `$context->rows`(全行)が渡る。チュートリアル第12章の `ArticleSearchResult` がこれ。 @@ -142,7 +154,7 @@ interface ArticleQueryInterface } ``` -## フラットな JOIN 結果を親子に組み立てる +### フラットな JOIN 結果を親子に組み立てる JOIN の結果は平らな行で返る。コメントを記事ごとにまとめるのは、テンプレートでもコントローラーでも面倒。`fromContext()` でまとめる。 @@ -190,7 +202,7 @@ final class Articles implements PostQueryInterface 1クエリでネストしたオブジェクトが返る。N+1 も、コントローラーでの手動グルーピングもない。 -## ソートは `fromContext()` で +### ソートは `fromContext()` で SQL の `ORDER BY` では届かない並びがある。`ORDER BY name` は辞書順なので `item1, item10, item2` になる。全行が揃ってから並べ替える。 @@ -220,7 +232,7 @@ final class FileList implements PostQueryInterface 業務固有の優先順位(`news → feature → opinion`)も同じ場所に書ける。`usort()` に `['news' => 0, 'feature' => 1, 'opinion' => 2]` を引かせるだけ。テンプレートは並び順を意識しない。 -## SPL イテレータでフィルタ・制限する +### SPL イテレータでフィルタ・制限する `PostQueryInterface` と `IteratorAggregate` を一緒に実装すると、「公開済みだけ、最大20件」のような絞り込みをコレクション自身に閉じ込められる。テンプレートで毎回 `{% if %}` を書かなくて済む。 @@ -263,7 +275,7 @@ final class Posts implements PostQueryInterface, IteratorAggregate `SplPriorityQueue` を使えば「ピン留めを先頭に、残りは日付順」も同じ場所に書ける。 -## Null Object — クエリは常に完成したエンティティを返す +### Null Object — クエリは常に完成したエンティティを返す 行が見つからないと、`type: 'row'` のクエリは `null` を返す。テンプレートに `{% if profile %}` が増える原因。`fromContext()` で空を判定し、常に完成したエンティティを返す。 diff --git a/docs/tutorial/bdr-patterns.md b/docs/tutorial/bdr-patterns.md index 827899a..dfbe7f5 100644 --- a/docs/tutorial/bdr-patterns.md +++ b/docs/tutorial/bdr-patterns.md @@ -1,5 +1,15 @@ +--- +layout: default +title: BDR Pattern Cookbook +description: "Per-row enrichment with the factory attribute vs. whole-result-set shaping with PostQueryInterface: badges, enums, JOIN grouping, sorting, SPL iterators, and Null Object." +lang: en +permalink: /tutorial/bdr-patterns/ +--- + # BDR Pattern Cookbook +[日本語 (Japanese)]({{ '/tutorial/bdr-patterns/ja/' | relative_url }}) | [Hands-on Tutorial]({{ '/tutorial/' | relative_url }}) + Ray.MediaQuery has two mechanisms for turning SQL results into objects. Which one you pick changes what you can do. | Mechanism | What it receives | Shape | Use for | @@ -11,10 +21,12 @@ Part 1 uses `factory:`, Part 2 uses `PostQueryInterface`. Confuse them and the c --- -# Part 1 — `factory:` per-row enrichment +## Part 1 — `factory:` per-row enrichment The factory is called **once per row**, with the SELECT columns passed as positional arguments. The return values line up, one per row. Chapter 7's `ArticleStatsFactory` is this. +> The mapping is by **position, not by name** (`PDO::FETCH_FUNC`). The factory signature must line up with the SELECT column list one-to-one, in the same order. A signature shorter than the column list does not "pick" the columns it names — it silently receives the leading ones. Each snippet below therefore assumes the SELECT returns exactly the columns its factory declares. + ```php interface ArticleQueryInterface { @@ -24,7 +36,7 @@ interface ArticleQueryInterface } ``` -## The `if` vanishes from templates +### The `if` vanishes from templates ```twig {# a familiar sight #} @@ -58,7 +70,7 @@ final class ArticleFactory {{ article.status }} ``` -## Receive a string column as an enum +### Receive a string column as an enum The `status` column is a string. Convert it to a PHP enum in the factory for type-safe comparisons. @@ -76,7 +88,7 @@ public function factory(int $id, string $status): Article A typo throws at `Status::from()` instead of silently failing in a template. No raw strings scattered around. -## Put display values on the entity +### Put display values on the entity "Show reading time from body length." "Format the price with commas." Compute it in the factory. @@ -98,7 +110,7 @@ public function factory(int $id, string $body, int $priceYen): Article No calculation in the template. The factory is also easy to unit-test in isolation. -## Inject the current time and current user +### Inject the current time and current user A factory can receive dependencies through its constructor (same as `age` in chapter 8). Build values that are not in any column from injected services. @@ -130,7 +142,7 @@ Bind `CurrentUserInterface` in Ray.Di; swap in `FakeCurrentUser` for tests. `Dat --- -# Part 2 — `PostQueryInterface` shaping the whole result set +## Part 2 — `PostQueryInterface` shaping the whole result set Anything that spans rows — grouping, sorting, filtering, emptiness checks — cannot be done in `factory:`, because `factory:` only ever sees one row at a time. When you need the whole result set, use `PostQueryInterface`. Declare the class as the return type; its `fromContext()` receives `$context->rows` (every row). Chapter 12's `ArticleSearchResult` is this. @@ -142,7 +154,7 @@ interface ArticleQueryInterface } ``` -## Assemble flat JOIN rows into a parent-child shape +### Assemble flat JOIN rows into a parent-child shape A JOIN returns flat rows. Grouping comments under each article is tedious in both the template and the controller. Do it in `fromContext()`. @@ -190,7 +202,7 @@ final class Articles implements PostQueryInterface One query, nested objects. No N+1, no manual grouping in the controller. -## Sort in `fromContext()` +### Sort in `fromContext()` Some orderings are out of `ORDER BY`'s reach. `ORDER BY name` is lexicographic, so it gives `item1, item10, item2`. Reorder once all rows are in hand. @@ -220,7 +232,7 @@ final class FileList implements PostQueryInterface Business-specific priority (`news → feature → opinion`) lives in the same place — feed `usort()` a `['news' => 0, 'feature' => 1, 'opinion' => 2]` map. The template never thinks about order. -## Filter and limit with SPL iterators +### Filter and limit with SPL iterators Implementing both `PostQueryInterface` and `IteratorAggregate` lets a collection own a rule like "published only, up to 20." No `{% if %}` repeated in the template. @@ -263,7 +275,7 @@ final class Posts implements PostQueryInterface, IteratorAggregate `SplPriorityQueue` lets you pin featured posts first, then fall back to date order — same place. -## Null Object — the query always returns a complete entity +### Null Object — the query always returns a complete entity When no row is found, a `type: 'row'` query returns `null` — the source of every `{% if profile %}` in a template. Check for emptiness in `fromContext()` and always return a complete entity.