Skip to content

Unify SELECT / Collection under PostQueryInterface (replaces #87)#89

Merged
koriym merged 5 commits into1.xfrom
collection-via-postquery
May 6, 2026
Merged

Unify SELECT / Collection under PostQueryInterface (replaces #87)#89
koriym merged 5 commits into1.xfrom
collection-via-postquery

Conversation

@koriym
Copy link
Copy Markdown
Member

@koriym koriym commented Apr 20, 2026

Implements the unified design from #88 and supersedes #87 (closed).

What changed

SELECT return types can now implement PostQueryInterface just like DML results. The framework pre-hydrates the row list into PostQueryContext::$rows; the factory wraps them in a typed collection that carries domain operations on the result set as a whole — which is what a raw array<Article> can't express.

use Ray\MediaQuery\Result\PostQueryContext;
use Ray\MediaQuery\Result\PostQueryInterface;

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

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

        return new static($rows);
    }

    /** Domain predicates / aggregations — the reason to wrap. */
    public function published(): self
    {
        return new self(array_values(array_filter(
            $this->rows,
            static fn (Article $a): bool => $a->isPublished(),
        )));
    }

    public function totalWordCount(): int
    {
        return array_sum(array_map(
            static fn (Article $a): int => $a->wordCount,
            $this->rows,
        ));
    }

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

interface ArticleRepository
{
    #[DbQuery('article_list')]
    /** @return Articles<Article> */
    public function list(): Articles;
}

Callers get $articles->published()->totalWordCount() — domain logic about the collection lives on the type, not scattered across services. IteratorAggregate / Countable just give the wrapper the normal "feels like an array" ergonomics on top. Wrapping a Laravel / Illuminate / Doctrine Collection via a property is the same pattern when you want a richer base.

Why this replaces #87

#87 auto-detected Traversable + instantiable + constructor-param-count via reflection and invoked new ReflectionClass(...)->newInstanceArgs([$rows]). The new approach:

  • One dispatch pathDbQueryInterceptor already had the is_subclass_of(..., PostQueryInterface::class) branch for DML. SELECT now rides on the same check. No separate detector class, no magic constructor invocation.
  • Composition over inheritance — users wrap rows via a property rather than extending a Collection base class. Works with any Collection library, and the wrapper can carry adjacent metadata.
  • Opt-in, explicitimplements PostQueryInterface + a small factory is the boilerplate cost. In exchange: type-safe static returns, alignment with the DML path, and no reflection-based magic.
  • Entity hydration stays in the existing pipelineFetchInterface / FetchFactory / Injector produce array<Article>, the framework hands it to the factory via $context->rows. The wrapper never touches raw rows or DI.

See #88 for the design memo and the rationale in full.

Implementation

  • src/Result/PostQueryContext.php — adds public readonly array $rows = []. Hydrated for SELECT, empty for DML.
  • src/SqlQueryInterface.php / src/SqlQuery.phpexecPostQuery gains FetchInterface|null $fetch = null. SqlQuery passes $fetch through perform() (already SELECT/DML-aware) and populates PostQueryContext::$rows with the returned rows.
  • src/DbQueryInterceptor.php — builds a FetchInterface via the existing FetchFactory and forwards it to execPostQuery for PostQueryInterface return types. No new branches.
  • src/ReturnEntity.php — treats PostQueryInterface implementations the same as PagesInterface: skip the class-name return so a docblock like @return Articles<Article> resolves to Article via the existing generic-extraction path.

Follow-up commit (fa84611)

Tightens documentation, tests, and generic typing without changing the production surface beyond docblocks:

  • Docs catch up with semanticsPostQueryInterface, SqlQueryInterface, and the README now reflect the unified SELECT + DML coverage. The original "DML results" wording was stale after this PR routed SELECT through the same path. Addresses Copilot review (missing @param FetchInterface|null $fetch).
  • README gains a SELECT example — a single-type Articles wrapper showing published() / totalWordCount() / IteratorAggregate / Countable, plus shape rules for $rows and the meaning of $rows === [].
  • README gains a generic example — a TypedRows<T> base class with extends TypedRows<Article> / extends TypedRows<User> for sharing a wrapper across entity types. The narrow happens at the @var list<T> line in fromContext(); from there Psalm and PHPStan honour the parameter through foreach, $rows[N], iterator_to_array(...), and any derived method.
  • Test fixtures decouple from todo_list.sql — new article_list.sql (with ORDER BY id) and article_list_empty.sql so the Articles tests no longer share fixtures with the rest of the suite.
  • Articles fake gains IteratorAggregate, Countable, isEmpty, and @template T — its @return Articles<Entity> propagation is verified end-to-end. As a positive side-effect, six runtime type checks (assertContainsOnlyInstancesOf × 2, assertInstanceOf × 4) fall away because the docblock carries the narrow.
  • Three new testsfactory: attribute combined with PostQueryInterface, empty SELECT, and the Countable / IteratorAggregate ergonomics.

Test plan

  • composer test — 101 tests, 176 assertions (was 98 before fa84611; +3 new tests, runtime instance assertions removed where the docblock narrow makes them redundant)
  • composer cs — clean
  • ./vendor-bin/tools/vendor/bin/phpstan --memory-limit=1G — no errors (level max)
  • ./vendor-bin/tools/vendor/bin/psalm --no-cache — no errors (error level 1)
  • DbQuerySelectPostQueryTest covers:
    • Articles wrapper returned from a SELECT
    • $context->rows as hydrated Article instances when @return Articles<Article> is declared
    • $context->rows as TodoConstruct instances when factory: TodoEntityFactory::class is supplied
    • $context->rows as raw assoc arrays when no entity type is declared
    • Empty SELECT returns an Articles wrapper with $rows === [], count() === 0, isEmpty() === true
    • iterator_to_array($articles) matches $articles->rows
  • Existing DML paths (AffectedRows, InsertedRow, DbQueryCustomPostQueryTest::RowCountWithQuery) still work — their $rows stays [], other fields unchanged

Refs #88 (design memo), #87 (closed, superseded).

Summary by CodeRabbit

  • New Features

    • SELECT queries now return typed result wrappers containing accessible rows instead of raw statements.
    • Results support multiple fetch strategies: associative arrays, hydrated entity instances, or factory-based construction.
    • Result wrappers are iterable and countable for convenient row access and inspection.
  • Tests

    • Added comprehensive test coverage for SELECT query result wrapping, hydration, and iteration behaviors.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

Warning

Rate limit exceeded

@koriym has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 51 minutes and 40 seconds before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fd9ade6a-fe38-4e5c-918a-8118f9139d27

📥 Commits

Reviewing files that changed from the base of the PR and between 86ea8c5 and 06499a4.

📒 Files selected for processing (1)
  • README.md
📝 Walkthrough

Walkthrough

DbQueryInterceptor now builds a Fetch for PostQuery return types and passes it into SqlQuery::execPostQuery; SqlQuery/SqlQueryInterface accept an optional Fetch and populate PostQueryContext::$rows with fetched SELECT rows. Tests, fakes, fixtures, and docs were added to exercise associative and hydrated SELECT results via PostQueryInterface wrappers.

Changes

SELECT Post-Query Flow

Layer / File(s) Summary
Data Shape
src/Result/PostQueryContext.php
Adds public readonly array $rows = [] to carry pre-hydrated SELECT rows (empty for DML).
Return-Type Resolution
src/ReturnEntity.php
Treat return types implementing PostQueryInterface as direct return types (bypass docblock extraction).
Core Execution API
src/SqlQueryInterface.php
execPostQuery signature gains optional `FetchInterface
Core Implementation
src/SqlQuery.php
`execPostQuery(..., FetchInterface
Interception / Dispatch
src/DbQueryInterceptor.php
invoke() calls FetchFactoryInterface::factory($dbQuery, $entity, $returnType) for PostQuery return types and supplies the resulting $postQueryFetch to execPostQuery.
Fakes / Compatibility
tests/Fake/FakeSqlQuery.php
Fake’s execPostQuery signature extended to accept optional `FetchInterface
Tests / Fixtures
tests/DbQuerySelectPostQueryTest.php, tests/sql/article_list.sql, tests/sql/article_list_empty.sql
New integration tests and SQL fixtures exercising associative rows, hydrated entities, factory-based hydration, and empty-select behavior.
Test Types & Entities
tests/Fake/Entity/Article.php, tests/Fake/Queries/ArticlesInterface.php, tests/Fake/Result/Articles.php
Adds Article entity, ArticlesInterface with list methods, and an Articles result wrapper implementing PostQueryInterface that consumes PostQueryContext::$rows and implements Countable/IteratorAggregate/isEmpty().
Documentation
README.md
New section on typed SELECT wrappers and updated execPostQuery docs showing optional fetch and row-shape semantics.

Sequence Diagram

sequenceDiagram
    participant Client
    participant DbQueryInterceptor
    participant FetchFactory
    participant SqlQuery
    participant PostQueryContext
    participant Articles

    Client->>DbQueryInterceptor: invoke(dbQuery, entity, returnType)
    DbQueryInterceptor->>FetchFactory: factory(dbQuery, entity, returnType)
    FetchFactory-->>DbQueryInterceptor: postQueryFetch
    DbQueryInterceptor->>SqlQuery: execPostQuery(sqlId, values, postQueryClass, postQueryFetch)
    SqlQuery->>SqlQuery: rows = perform(sqlId, values, postQueryFetch)
    SqlQuery->>PostQueryContext: __construct(statement, pdo, lastValues, rows)
    SqlQuery->>Articles: fromContext(PostQueryContext)
    Articles-->>Client: Articles instance (uses context.rows)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related issues

Possibly related PRs

  • ray-di/Ray.MediaQuery#85: Prior work on PostQuery dispatch that this change extends by adding optional Fetch and carrying rows into PostQueryContext.
  • ray-di/Ray.MediaQuery#64: Modifies DbQueryInterceptor/exec flow; related to return-type/factory resolution.
  • ray-di/Ray.MediaQuery#91: Related threading of factory/fetch information through query execution to honor factory-driven hydration.

Suggested reviewers

  • NaokiTsuchiya
  • jingu

Poem

🐇 I nibble rows both raw and dressed,

Context holds the harvest pressed,
A factory fetch hops into place,
Articles counted, neat, and chaste,
The little rabbit stamps with glee.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 59.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: unifying SELECT and collection handling through PostQueryInterface, which is the core design objective across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch collection-via-postquery

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@koriym koriym marked this pull request as ready for review April 20, 2026 06:03
@koriym
Copy link
Copy Markdown
Member Author

koriym commented Apr 20, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/DbQuerySelectPostQueryTest.php (1)

28-28: Nit: simplify sql directory path.

dirname(__DIR__) . '/tests/sql' resolves to <repo>/tests/sql, but since the test file already lives in tests/, this is equivalent to __DIR__ . '/sql' and avoids assuming the parent directory name is tests.

Proposed tweak
-        $sqlDir = dirname(__DIR__) . '/tests/sql';
+        $sqlDir = __DIR__ . '/sql';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/DbQuerySelectPostQueryTest.php` at line 28, Nit: simplify sql directory
path — replace the computed dirname expression with the simpler relative path;
in the test file DbQuerySelectPostQueryTest.php change the assignment to $sqlDir
so it uses __DIR__ . '/sql' instead of dirname(__DIR__) . '/tests/sql' to avoid
assuming the parent directory name; update the $sqlDir variable initialization
(look for the line that assigns $sqlDir) and run tests to confirm paths still
resolve.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/SqlQueryInterface.php`:
- Around line 64-67: The docs currently imply a non-null $fetch always yields
entity instances; clarify that row shape depends on the fetch strategy produced
by FetchFactory (e.g., FetchAssoc yields associative arrays). Update the
sentence describing SELECT behavior to state that for SELECT statements the rows
exposed on the context's `$rows` property are shaped according to the provided
`$fetch` strategy (entity instances when the fetch returns entities, associative
arrays when using FetchAssoc, etc.), while for DML statements `$rows` remains
`[]`. Ensure references to `$fetch`, `FetchFactory`, `FetchAssoc`, and the
context's `$rows` are preserved so callers can locate the behavior.

In `@tests/DbQuerySelectPostQueryTest.php`:
- Around line 42-74: The tests testReturnsArticlesWrapperWithAssocRows and
testReturnsArticlesWrapperWithHydratedEntities assume insertion order; to fix,
make the expectation order-independent by either (A) adding a deterministic
ORDER BY id to the query used by ArticlesInterface::listAssoc and
ArticlesInterface::listHydrated (ensure the SQL that builds those results—or
todo_list.sql fixture—includes "ORDER BY id"), or (B) change the tests to
normalize/sort the returned rows before asserting (e.g., sort $result->rows by
'id' or reindex by id before comparing and before accessing
$result->rows[0]/[1]) so they no longer rely on implicit DB order. Use the
symbols ArticlesInterface::listAssoc, ArticlesInterface::listHydrated, Articles
(rows), Article (entities), and todo_list.sql to locate where to apply the
change.

---

Nitpick comments:
In `@tests/DbQuerySelectPostQueryTest.php`:
- Line 28: Nit: simplify sql directory path — replace the computed dirname
expression with the simpler relative path; in the test file
DbQuerySelectPostQueryTest.php change the assignment to $sqlDir so it uses
__DIR__ . '/sql' instead of dirname(__DIR__) . '/tests/sql' to avoid assuming
the parent directory name; update the $sqlDir variable initialization (look for
the line that assigns $sqlDir) and run tests to confirm paths still resolve.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4940a3f3-5004-4891-b25a-826b4973ec06

📥 Commits

Reviewing files that changed from the base of the PR and between cbd5faa and 56c9bda.

📒 Files selected for processing (10)
  • src/DbQueryInterceptor.php
  • src/Result/PostQueryContext.php
  • src/ReturnEntity.php
  • src/SqlQuery.php
  • src/SqlQueryInterface.php
  • tests/DbQuerySelectPostQueryTest.php
  • tests/Fake/Entity/Article.php
  • tests/Fake/FakeSqlQuery.php
  • tests/Fake/Queries/ArticlesInterface.php
  • tests/Fake/Result/Articles.php

Comment thread src/SqlQueryInterface.php Outdated
Comment thread tests/DbQuerySelectPostQueryTest.php
Base automatically changed from affected-rows to 1.x April 20, 2026 15:07
SELECT return types can now implement `PostQueryInterface`. The framework
pre-hydrates the result set into `PostQueryContext::$rows` (entity
instances when an entity is configured, associative arrays otherwise),
so a collection wrapper like `Articles<Article>` can be composed by the
factory without re-fetching.

- `PostQueryContext`: add `array $rows = []` (empty for DML paths).
- `SqlQueryInterface::execPostQuery` / `SqlQuery::execPostQuery`: accept
  an optional `FetchInterface` and feed hydrated rows into the context.
- `DbQueryInterceptor`: build a fetch strategy via `FetchFactory` for
  `PostQueryInterface` return types and forward it.
- `ReturnEntity`: bypass class-name resolution for `PostQueryInterface`
  wrappers (same treatment as `PagesInterface`) so the docblock's
  `Articles<Article>` generic yields `Article` as the row entity.
@koriym koriym force-pushed the collection-via-postquery branch from 56c9bda to 8acc97b Compare April 20, 2026 15:09
- SqlQueryInterface docblock: row shape follows the `$fetch` strategy
  (not "entity vs none"). FetchFactory may return FetchAssoc, which
  produces associative arrays even with $fetch non-null.
- DbQuerySelectPostQueryTest: don't rely on implicit DB order. Sort
  rows by id before asserting; decouples the tests from todo_list.sql
  (which has no ORDER BY and is shared with many other tests).
@koriym
Copy link
Copy Markdown
Member Author

koriym commented Apr 20, 2026

@coderabbitai No outstanding blockers. Please resolve all open conversations and approve this PR.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

Tip

For best results, initiate chat on the files or code changes.

@koriym: Understood! Resolving all open conversations and approving the PR now.

[resolve]
[approve]

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the existing PostQueryInterface mechanism so SELECT queries can return typed “post-query” result objects (e.g., domain collections) in the same way DML queries return AffectedRows/InsertedRow. It does so by pre-hydrating SELECT rows in the framework and exposing them via PostQueryContext::$rows, then letting the PostQueryInterface::fromContext() factory wrap/compose the result.

Changes:

  • Add hydrated SELECT rows to PostQueryContext and plumb them through SqlQuery::execPostQuery().
  • Update the interceptor to provide a FetchInterface when dispatching PostQueryInterface return types (SELECT + DML share the same dispatch).
  • Add new fakes + an integration test covering SELECT returning a PostQueryInterface wrapper with both assoc rows and hydrated entities.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/Fake/Result/Articles.php Adds a fake SELECT-path PostQueryInterface wrapper used by tests.
tests/Fake/Queries/ArticlesInterface.php Defines test repo methods returning Articles with/without docblock-driven hydration.
tests/Fake/FakeSqlQuery.php Updates execPostQuery signature to match the interface change.
tests/Fake/Entity/Article.php Adds a simple entity used for hydration assertions.
tests/DbQuerySelectPostQueryTest.php New integration test validating SELECT → PostQueryInterface wrapper and $context->rows hydration.
src/SqlQueryInterface.php Extends execPostQuery to accept an optional FetchInterface for SELECT hydration.
src/SqlQuery.php Passes fetched rows into PostQueryContext for SELECT; keeps DML rows as [].
src/ReturnEntity.php Avoids treating PostQueryInterface return types as “entity” return types so generics can resolve entity correctly.
src/Result/PostQueryContext.php Adds public readonly array $rows = [] and documents SELECT vs DML behavior.
src/DbQueryInterceptor.php Builds a FetchInterface and forwards it into execPostQuery for PostQueryInterface return types.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/SqlQueryInterface.php
Comment on lines 72 to +80
@@ -71,7 +77,7 @@ public function exec(string $sqlId, array $values = [], FetchInterface|null $fet
* @template T of PostQueryInterface
* @psalm-taint-escape sql
*/
public function execPostQuery(string $sqlId, array $values, string $postQueryClass): PostQueryInterface;
public function execPostQuery(string $sqlId, array $values, string $postQueryClass, FetchInterface|null $fetch = null): PostQueryInterface;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The execPostQuery() PHPDoc lists $values and $postQueryClass but the method signature now also accepts $fetch. Please add a @param FetchInterface|null $fetch entry so the documentation and static-analysis hints match the actual API.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Fixed in commit fa84611@param FetchInterface|null $fetch is now in the PHPDoc with a description of the SELECT hydration strategy.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/ReturnEntity.php:53

  • ReturnEntity now excludes any return type that implements PostQueryInterface from being treated as the entity class. Because docblock() only extracts an entity from array<T> / Generic<T> return annotations, a SELECT method whose return type is itself an entity implementing PostQueryInterface (i.e. no @return Wrapper<Entity> generic) will end up with $entity === null and therefore skip entity hydration (rows become assoc arrays). Consider falling back to the concrete return type as the entity when docblock() returns null, while still preferring the docblock generic when present (so wrapper types like Articles<Article> keep working).
        if (
            class_exists($returnTypeClass)
            && ! is_a($returnTypeClass, PagesInterface::class, true)
            && ! is_a($returnTypeClass, PostQueryInterface::class, true)
        ) {
            return $returnTypeClass;
        }

        return $this->docblock($method);
    }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Builds on the SELECT-side PostQueryInterface introduced in 8acc97b:

- PostQueryInterface / SqlQueryInterface / README docs now reflect the
  unified SELECT + DML semantics; the original DML-only wording was
  stale after this PR routed SELECT through the same dispatch.
- New fixtures (article_list.sql / article_list_empty.sql) decouple
  the Articles tests from the shared todo_list.sql so the test no
  longer relies on implicit row order or unrelated edits.
- Articles fake gains IteratorAggregate, Countable, isEmpty, plus a
  @template T parameter so @return Articles<Entity> propagates Entity
  through to $rows[N], foreach, and iterator_to_array — six runtime
  type checks fall away because the docblock now carries the narrow.
- Three new tests cover factory: combined with PostQueryInterface,
  empty SELECT, and the Countable / IteratorAggregate ergonomics.
- README adds a "Generic base for reuse across repositories" example
  showing TypedRows<T> + extends TypedRows<Article> as the pattern
  for sharing a wrapper across entity types.

Addresses Copilot review on src/SqlQueryInterface.php (missing @param
FetchInterface|null $fetch).

composer test (101 / 176), composer cs, composer sa — all clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@koriym
Copy link
Copy Markdown
Member Author

koriym commented Apr 25, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 25, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@koriym
Copy link
Copy Markdown
Member Author

koriym commented Apr 25, 2026

@NaokiTsuchiya 今日のcommitで

/** @implements IteratorAggregate<int, Article> */

と宣言することで、以下のようにArticlesをループで回したときにArticleが得られるのが宣言できるようになりました。

interface ArticleRepository
{
    #[DbQuery('article_list')]
    /** @return Articles<Article> */
    public function list(): Articles;
}

(以前はmixedでした)

@koriym
Copy link
Copy Markdown
Member Author

koriym commented Apr 30, 2026

What this PR enables

Before this, SELECT can return Entity or array<Entity>. Collection-level domain operations — predicates, aggregations, projections — have no canonical home, so apps grow ad-hoc collection classes the framework doesn't see.

After this, SELECT returns ride the same PostQueryInterface path as DML, so a user-defined result class becomes the place where collection-level domain logic lives:

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

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

    public function published(): self          { /* domain predicate */ }
    public function totalWordCount(): int      { /* aggregation */ }
    public function getIterator(): ArrayIterator { return new ArrayIterator($this->rows); }
    public function count(): int               { return count($this->rows); }
}
$articles->published()->totalWordCount();              // domain language
foreach ($articles as $a) { /* $a: Article via @return Articles<Article> */ }
count($articles);                                      // Countable

Where this matters

Domain Operations that now have a home
Catalog / commerce Products::inStock()->byPriceRange($lo, $hi)->popular(10)
Finance Transactions::reconciled()->disputed()->total(), Invoices::overdue()->total()
Booking / scheduling Bookings::confirmed()->forSlot($s), Slots::available()->onDate($d)
Reporting / analytics Sales::byMonth($m)->byCategory($c)->topN(10)
Workflow Tickets::open()->highPriority()->assignedTo($u)
Content / search Articles::published()->byTag($t)->mostRead(20)

These aren't novel patterns. The novelty is that they now have a framework-blessed shape: single-dispatch routing, generic-aware return types, and IteratorAggregate / Countable ergonomics that compose with the rest of PHP's iterable surface.

Design choices worth highlighting

  • Composition over inheritance. Wrappers hold rows via property rather than extend a Collection base. Works with Doctrine Collection, Illuminate Collection, or a custom one — no library lock-in.
  • Explicit implements PostQueryInterface, no reflection magic. Intent is grep-discoverable; behaviour is predictable.
  • Single dispatch path shared with DML. is_subclass_of($returnType, PostQueryInterface::class) covers this and AffectedRows / InsertedRow. New typed results plug in without interceptor changes.
  • Generics narrow at one line. @return Articles<Article> + @var list<T> in fromContext is enough for Psalm / PHPStan to honour the parameter through foreach, indexed access, and derived methods.

Adoption note for HAL / JSON consumers

Wrappers landing in $this->body currently need either a JsonSerializable impl or a manual iterator_to_array(...) at the resource. A renderer-side companion change in BEAR.Resource — body normalisation that walks Traversable automatically — would remove that friction non-breakingly:

private function normalize(mixed $v): mixed {
    if ($v instanceof JsonSerializable) return $v;
    if ($v instanceof Traversable)      return array_map([$this, 'normalize'], iterator_to_array($v));
    if (is_array($v))                    return array_map([$this, 'normalize'], $v);
    return $v;
}

Existing array<Entity> and JsonSerializable paths are unchanged; new PostQueryInterface wrappers drop into body untouched. With that in place, this PR's wrapper is zero-cost when ignored, zero-friction when adopted, high-value when invoked.

Minor README suggestion on TypedRows<T> placement

Lead the example with composition (Articles { public readonly array $rows; }) and present TypedRows<T> as an opt-in generic helper for sharing wrappers across entity types. As written the two examples sit side-by-side and the default path isn't obvious.

Show that PostQueryInterface dispatches on the last executed statement,
so a single repository method can run a DML and return a typed view of
the affected row via a trailing SELECT — no driver-specific RETURNING.
Clarifies the meaning of $context->rows === [].
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Around line 424-428: The example uses the SQLite-specific function
last_insert_rowid() in create_article.sql which is not portable; update the
README to mark that SELECT * FROM articles WHERE id = last_insert_rowid(); is
SQLite-only and provide alternatives (e.g., use RETURNING * for
PostgreSQL/MySQL: INSERT ... RETURNING *, or call your DB driver’s
lastInsertId()/getGeneratedKeys() API) and mention that driver-specific helper
functions should be used when RETURNING is not available.
- Line 710: Update the README method signature for execPostQuery to document the
concrete fetch type by replacing or annotating `$fetch = null` with
`FetchInterface|null $fetch = null`; specifically, in the method list entry that
currently reads "execPostQuery($queryId, $params, $postQueryClass, $fetch =
null)", change it to reference FetchInterface so readers understand the typed
fetch forwarding and the accepted null value (use the symbol execPostQuery and
the FetchInterface name to locate the text).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c300fc0e-d72e-4c7b-a006-cafaeaa98d7f

📥 Commits

Reviewing files that changed from the base of the PR and between fa84611 and 86ea8c5.

📒 Files selected for processing (1)
  • README.md

Comment thread README.md
Comment thread README.md Outdated
- Mark `last_insert_rowid()` in the DML+SELECT example as SQLite-specific
  and point to `LAST_INSERT_ID()` / `RETURNING` for other drivers.
- Spell out `FetchInterface|null $fetch = null` in the execPostQuery
  method-list entry so the typed fetch forwarding is visible at a glance.
@koriym
Copy link
Copy Markdown
Member Author

koriym commented May 6, 2026

@coderabbitai No outstanding blockers. Please resolve all open conversations and approve this PR.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

Tip

For best results, initiate chat on the files or code changes.

@koriym: Understood! Resolving all open conversations and approving the PR now.

[resolve]
[approve]

@koriym koriym merged commit cee5525 into 1.x May 6, 2026
19 of 20 checks passed
@koriym koriym deleted the collection-via-postquery branch May 6, 2026 13:26
Copilot stopped work on behalf of koriym due to an error May 6, 2026 13:26
koriym added a commit that referenced this pull request May 6, 2026
PR #89 broadens PostQueryInterface from DML-only to cover SELECT typed
collection wrappers. Update the AI-oriented reference to document the
new $rows field on PostQueryContext, the typed-collection pattern, the
DML+SELECT use case, and the new $fetch parameter on execPostQuery.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants