Skip to content

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

Open
koriym wants to merge 3 commits into1.xfrom
collection-via-postquery
Open

Unify SELECT / Collection under PostQueryInterface (replaces #87)#89
koriym wants to merge 3 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).

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

📝 Walkthrough

Walkthrough

DbQueryInterceptor now constructs and supplies a Fetch (when the return type implements PostQueryInterface) to execPostQuery; SqlQuery/SqlQueryInterface accept an optional Fetch and pass fetched rows into PostQueryContext::$rows. Tests, fakes, and test fixtures were added to validate associative and hydrated SELECT results.

Changes

Cohort / File(s) Summary
Core PostQuery Contract
src/SqlQueryInterface.php, src/SqlQuery.php
execPostQuery signature gains optional `FetchInterface
PostQuery Context & Return Type Resolution
src/Result/PostQueryContext.php, src/ReturnEntity.php
PostQueryContext adds public readonly array $rows = []; ReturnEntity::__invoke() treats types implementing PostQueryInterface as direct return types (bypass docblock extraction).
Query Interception & Fetch Dispatch
src/DbQueryInterceptor.php
DbQueryInterceptor::invoke() creates $postQueryFetch via FetchFactoryInterface::factory(...) for PostQuery return types and passes it to execPostQuery.
Tests: Entities, Result Wrapper & Interface
tests/Fake/Entity/Article.php, tests/Fake/Queries/ArticlesInterface.php, tests/Fake/Result/Articles.php
Added Article entity, ArticlesInterface with assorted list methods, and Articles result wrapper implementing PostQueryInterface that consumes $context->rows and exposes count/iteration.
Tests: Execution & Fakes
tests/DbQuerySelectPostQueryTest.php, tests/Fake/FakeSqlQuery.php, tests/sql/...
New integration tests for SELECT post-query behavior; FakeSqlQuery::execPostQuery signature updated; added SQL fixtures article_list.sql and article_list_empty.sql.

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

  • Issue #88: Matches the proposal implemented here—PostQueryContext::$rows, interceptor routing SELECTs through a Fetch, and execPostQuery fetch parameter.
  • Issue #84: Related changes to interceptor/exec flow; touches same execution dispatch concerns.

Possibly related PRs

  • PR #85: Prior work on PostQuery dispatch that this change extends by adding optional Fetch and carrying rows into PostQueryContext.
  • PR #64: Modifies DbQueryInterceptor::invoke() as well; related to how PostQuery fetch/factory is resolved.

Suggested reviewers

  • jingu
  • NaokiTsuchiya

Poem

🐇 I nibble rows both raw and dressed,

Context keeps the harvest best,
Fetches hop from factory lair,
Articles leap—counted, shared, and bare,
A tiny rabbit claps with zest!

🚥 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
Title check ✅ Passed The title 'Unify SELECT / Collection under PostQueryInterface (replaces #87)' clearly and specifically summarizes the main change: unifying SELECT and collection handling through PostQueryInterface, addressing a prior attempt.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

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

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

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.

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