From 8acc97bb51969c8bab61c1352ccdccb2d7a8a529 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Mon, 20 Apr 2026 14:50:59 +0900 Subject: [PATCH 1/5] Extend PostQueryInterface to SELECT paths with pre-hydrated rows 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
` 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
` generic yields `Article` as the row entity. --- src/DbQueryInterceptor.php | 4 +- src/Result/PostQueryContext.php | 12 +++- src/ReturnEntity.php | 7 ++- src/SqlQuery.php | 6 +- src/SqlQueryInterface.php | 21 ++++--- tests/DbQuerySelectPostQueryTest.php | 75 ++++++++++++++++++++++++ tests/Fake/Entity/Article.php | 14 +++++ tests/Fake/FakeSqlQuery.php | 2 +- tests/Fake/Queries/ArticlesInterface.php | 26 ++++++++ tests/Fake/Result/Articles.php | 29 +++++++++ 10 files changed, 180 insertions(+), 16 deletions(-) create mode 100644 tests/DbQuerySelectPostQueryTest.php create mode 100644 tests/Fake/Entity/Article.php create mode 100644 tests/Fake/Queries/ArticlesInterface.php create mode 100644 tests/Fake/Result/Articles.php diff --git a/src/DbQueryInterceptor.php b/src/DbQueryInterceptor.php index 791762a..3be0cc6 100644 --- a/src/DbQueryInterceptor.php +++ b/src/DbQueryInterceptor.php @@ -53,7 +53,9 @@ public function invoke(MethodInvocation $invocation): array|object|null if ($returnType instanceof ReflectionNamedType) { $typeName = $returnType->getName(); if (class_exists($typeName) && is_subclass_of($typeName, PostQueryInterface::class)) { - return $this->sqlQuery->execPostQuery($dbQuery->id, $values, $typeName); + $postQueryFetch = $this->factory->factory($dbQuery, $entity, $returnType); + + return $this->sqlQuery->execPostQuery($dbQuery->id, $values, $typeName, $postQueryFetch); } } diff --git a/src/Result/PostQueryContext.php b/src/Result/PostQueryContext.php index 21abfc1..65d70fc 100644 --- a/src/Result/PostQueryContext.php +++ b/src/Result/PostQueryContext.php @@ -8,21 +8,29 @@ use PDOStatement; /** - * Context passed to {@see PostQueryInterface::fromContext()} after DML execution. + * Context passed to {@see PostQueryInterface::fromContext()} after execution. * * Carries the executed statement, the connection, and the parameter values as * resolved by `ParamConverter` / `ParamInjector` — i.e. with injected defaults * (UUIDs, timestamps), `DateTime` converted to SQL strings, and `ToScalarInterface` * value objects reduced to scalars. These resolved values are what actually went * to the driver, and they are not otherwise observable by the caller. + * + * For SELECT paths, `$rows` holds the pre-hydrated result set (entity instances + * when an entity is configured, or associative arrays otherwise). For DML paths + * no fetch happens and `$rows` is `[]`. */ final class PostQueryContext { - /** @param array $values */ + /** + * @param array $values + * @param array $rows Hydrated rows for SELECT; `[]` for DML. + */ public function __construct( public readonly PDOStatement $statement, public readonly ExtendedPdoInterface $pdo, public readonly array $values, + public readonly array $rows = [], ) { } } diff --git a/src/ReturnEntity.php b/src/ReturnEntity.php index b846bba..0fe3088 100644 --- a/src/ReturnEntity.php +++ b/src/ReturnEntity.php @@ -12,6 +12,7 @@ use phpDocumentor\Reflection\Types\Array_; use phpDocumentor\Reflection\Types\ContextFactory; use phpDocumentor\Reflection\Types\Object_; +use Ray\MediaQuery\Result\PostQueryInterface; use ReflectionMethod; use ReflectionNamedType; use ReflectionType; @@ -40,7 +41,11 @@ public function __invoke(ReflectionMethod $method): string|null $returnTypeClass = $this->getReturnTypeName($returnType); - if (class_exists($returnTypeClass) && ! is_a($returnTypeClass, PagesInterface::class, true)) { + if ( + class_exists($returnTypeClass) + && ! is_a($returnTypeClass, PagesInterface::class, true) + && ! is_a($returnTypeClass, PostQueryInterface::class, true) + ) { return $returnTypeClass; } diff --git a/src/SqlQuery.php b/src/SqlQuery.php index 3986bd1..0bb8f39 100644 --- a/src/SqlQuery.php +++ b/src/SqlQuery.php @@ -107,12 +107,12 @@ public function getRowList(string $sqlId, array $values = [], FetchInterface|nul * @psalm-taint-escape sql */ #[Override] - public function execPostQuery(string $sqlId, array $values, string $postQueryClass): PostQueryInterface + public function execPostQuery(string $sqlId, array $values, string $postQueryClass, FetchInterface|null $fetch = null): PostQueryInterface { - $this->perform($sqlId, $values, null); + $rows = $this->perform($sqlId, $values, $fetch); assert($this->pdoStatement instanceof PDOStatement); - $context = new PostQueryContext($this->pdoStatement, $this->pdo, $this->lastValues); + $context = new PostQueryContext($this->pdoStatement, $this->pdo, $this->lastValues, $rows); return $postQueryClass::fromContext($context); } diff --git a/src/SqlQueryInterface.php b/src/SqlQueryInterface.php index 5de6e42..0f5abc0 100644 --- a/src/SqlQueryInterface.php +++ b/src/SqlQueryInterface.php @@ -15,10 +15,11 @@ * - `get*` — SELECT queries; execute and return the result set or a * derivation of it ({@see self::getRow()}, {@see self::getRowList()}, * {@see self::getCount()}, {@see self::getPages()}). - * - `exec*` — DML queries (INSERT / UPDATE / DELETE); execute and either - * return nothing ({@see self::exec()}) or return a typed result - * built from the post-execution PDO state - * ({@see self::execPostQuery()}). + * - `exec*` — execute and build a typed result. {@see self::exec()} runs a + * DML statement without reading a result. + * {@see self::execPostQuery()} dispatches through the user-defined + * {@see PostQueryInterface} factory for either SELECT (hydrated + * rows available on the context) or DML (post-execution PDO state). */ interface SqlQueryInterface { @@ -55,13 +56,17 @@ public function getRowList(string $sqlId, array $values = [], FetchInterface|nul public function exec(string $sqlId, array $values = [], FetchInterface|null $fetch = null): void; /** - * Execute a DML statement and build a result through the given PostQuery class. + * Execute a SQL statement and build a result through the given PostQuery class. * * The framework calls `{$postQueryClass}::fromContext($context)` after * executing the SQL. Each result class owns its own construction logic, so * the caller's return-type declaration is what selects behaviour (count only, - * count + last insert id, etc.). When the SQL file contains multiple - * statements, the result reflects the last executed statement only. + * count + last insert id, a typed collection wrapper, etc.). For SELECT + * statements the rows are pre-hydrated and exposed on the context's `$rows` + * property — entity instances when `$fetch` is provided, associative arrays + * otherwise. For DML statements no fetch happens and `$rows` is `[]`. When + * the SQL file contains multiple statements, the result reflects the last + * executed statement only. * * @param array $values * @param class-string $postQueryClass @@ -71,7 +76,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; /** * Return the total row count for a SELECT. Used as the pagination denominator. diff --git a/tests/DbQuerySelectPostQueryTest.php b/tests/DbQuerySelectPostQueryTest.php new file mode 100644 index 0000000..077e861 --- /dev/null +++ b/tests/DbQuerySelectPostQueryTest.php @@ -0,0 +1,75 @@ + true]), // @phpstan-ignore-line + ); + $this->injector = new Injector($module, __DIR__ . '/tmp'); + $pdo = $this->injector->getInstance(ExtendedPdoInterface::class); + $pdo->query((string) file_get_contents($sqlDir . '/create_todo.sql')); + $pdo->perform((string) file_get_contents($sqlDir . '/todo_add.sql'), ['id' => '1', 'title' => 'run']); + $pdo->perform((string) file_get_contents($sqlDir . '/todo_add.sql'), ['id' => '2', 'title' => 'walk']); + } + + public function testReturnsArticlesWrapperWithAssocRows(): void + { + $repo = $this->injector->getInstance(ArticlesInterface::class); + $result = $repo->listAssoc(); + + $this->assertInstanceOf(Articles::class, $result); + $this->assertSame( + [ + ['id' => '1', 'title' => 'run'], + ['id' => '2', 'title' => 'walk'], + ], + $result->rows, + ); + } + + public function testReturnsArticlesWrapperWithHydratedEntities(): void + { + $repo = $this->injector->getInstance(ArticlesInterface::class); + $result = $repo->listHydrated(); + + $this->assertInstanceOf(Articles::class, $result); + $this->assertCount(2, $result->rows); + + [$first, $second] = [$result->rows[0], $result->rows[1]]; + + $this->assertInstanceOf(Article::class, $first); + $this->assertSame('1', $first->id); + $this->assertSame('run', $first->title); + + $this->assertInstanceOf(Article::class, $second); + $this->assertSame('2', $second->id); + $this->assertSame('walk', $second->title); + } +} diff --git a/tests/Fake/Entity/Article.php b/tests/Fake/Entity/Article.php new file mode 100644 index 0000000..ed83d4c --- /dev/null +++ b/tests/Fake/Entity/Article.php @@ -0,0 +1,14 @@ + $values */ - public function execPostQuery(string $sqlId, array $values, string $postQueryClass): PostQueryInterface + public function execPostQuery(string $sqlId, array $values, string $postQueryClass, FetchInterface|null $fetch = null): PostQueryInterface { throw new LogicException('FakeSqlQuery does not support execPostQuery'); } diff --git a/tests/Fake/Queries/ArticlesInterface.php b/tests/Fake/Queries/ArticlesInterface.php new file mode 100644 index 0000000..7a7dd4b --- /dev/null +++ b/tests/Fake/Queries/ArticlesInterface.php @@ -0,0 +1,26 @@ + + */ + #[DbQuery('todo_list')] + public function listHydrated(): Articles; +} diff --git a/tests/Fake/Result/Articles.php b/tests/Fake/Result/Articles.php new file mode 100644 index 0000000..a555ec0 --- /dev/null +++ b/tests/Fake/Result/Articles.php @@ -0,0 +1,29 @@ +rows`, pre-hydrated by the framework (entity instances when an + * entity is configured, associative arrays otherwise). + */ +final class Articles implements PostQueryInterface +{ + /** @param array $rows */ + public function __construct( + public readonly array $rows, + ) { + } + + #[Override] + public static function fromContext(PostQueryContext $context): static + { + return new static($context->rows); + } +} From 0b5cb4c6637c8a8e0550760a55d179fe0428f389 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Tue, 21 Apr 2026 00:13:53 +0900 Subject: [PATCH 2/5] Address CodeRabbit review on PR #89 - 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). --- src/SqlQueryInterface.php | 11 ++++++----- tests/DbQuerySelectPostQueryTest.php | 20 ++++++++++++-------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/SqlQueryInterface.php b/src/SqlQueryInterface.php index 0f5abc0..6c4120a 100644 --- a/src/SqlQueryInterface.php +++ b/src/SqlQueryInterface.php @@ -62,11 +62,12 @@ public function exec(string $sqlId, array $values = [], FetchInterface|null $fet * executing the SQL. Each result class owns its own construction logic, so * the caller's return-type declaration is what selects behaviour (count only, * count + last insert id, a typed collection wrapper, etc.). For SELECT - * statements the rows are pre-hydrated and exposed on the context's `$rows` - * property — entity instances when `$fetch` is provided, associative arrays - * otherwise. For DML statements no fetch happens and `$rows` is `[]`. When - * the SQL file contains multiple statements, the result reflects the last - * executed statement only. + * statements the rows are fetched and exposed on the context's `$rows` + * property — shape is determined by the supplied `$fetch` strategy (entity + * instances for an entity-bound fetch, associative arrays for `FetchAssoc`), + * or associative arrays when `$fetch` is null. For DML statements no fetch + * happens and `$rows` is `[]`. When the SQL file contains multiple + * statements, the result reflects the last executed statement only. * * @param array $values * @param class-string $postQueryClass diff --git a/tests/DbQuerySelectPostQueryTest.php b/tests/DbQuerySelectPostQueryTest.php index 077e861..ea66f4f 100644 --- a/tests/DbQuerySelectPostQueryTest.php +++ b/tests/DbQuerySelectPostQueryTest.php @@ -15,6 +15,7 @@ use function dirname; use function file_get_contents; +use function usort; class DbQuerySelectPostQueryTest extends TestCase { @@ -45,12 +46,15 @@ public function testReturnsArticlesWrapperWithAssocRows(): void $result = $repo->listAssoc(); $this->assertInstanceOf(Articles::class, $result); + /** @var list $rows */ + $rows = $result->rows; + usort($rows, static fn (array $a, array $b): int => $a['id'] <=> $b['id']); $this->assertSame( [ ['id' => '1', 'title' => 'run'], ['id' => '2', 'title' => 'walk'], ], - $result->rows, + $rows, ); } @@ -62,14 +66,14 @@ public function testReturnsArticlesWrapperWithHydratedEntities(): void $this->assertInstanceOf(Articles::class, $result); $this->assertCount(2, $result->rows); - [$first, $second] = [$result->rows[0], $result->rows[1]]; + /** @var list
$rows */ + $rows = $result->rows; + usort($rows, static fn (Article $a, Article $b): int => $a->id <=> $b->id); - $this->assertInstanceOf(Article::class, $first); - $this->assertSame('1', $first->id); - $this->assertSame('run', $first->title); + $this->assertSame('1', $rows[0]->id); + $this->assertSame('run', $rows[0]->title); - $this->assertInstanceOf(Article::class, $second); - $this->assertSame('2', $second->id); - $this->assertSame('walk', $second->title); + $this->assertSame('2', $rows[1]->id); + $this->assertSame('walk', $rows[1]->title); } } From fa846117d86dec402e90aa490b904ebe9f83bd32 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Sat, 25 Apr 2026 19:27:51 +0900 Subject: [PATCH 3/5] Strengthen SELECT post-query coverage and demonstrate generic typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 + extends TypedRows
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 --- README.md | 108 ++++++++++++++++++++++- src/Result/PostQueryInterface.php | 14 +-- src/SqlQueryInterface.php | 2 + tests/DbQuerySelectPostQueryTest.php | 52 +++++++++-- tests/Fake/Queries/ArticlesInterface.php | 25 +++++- tests/Fake/Result/Articles.php | 50 +++++++++-- tests/sql/article_list.sql | 3 + tests/sql/article_list_empty.sql | 3 + 8 files changed, 233 insertions(+), 24 deletions(-) create mode 100644 tests/sql/article_list.sql create mode 100644 tests/sql/article_list_empty.sql diff --git a/README.md b/README.md index c33f0f7..4c30ea8 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,112 @@ final class RowCountWithQuery implements PostQueryInterface Declare it on any `#[DbQuery]` method and the interceptor dispatches to the class's own factory. +**SELECT collections — typed row wrappers:** + +`PostQueryInterface` also covers SELECT. The framework pre-hydrates the result set into `PostQueryContext::$rows` (entity instances when a `factory:` attribute or `@return Wrapper` docblock resolves an entity, associative arrays otherwise). Your wrapper class composes those rows — it never touches raw `PDOStatement` or DI: + +```php +use ArrayIterator; +use Countable; +use IteratorAggregate; +use Ray\MediaQuery\Result\PostQueryContext; +use Ray\MediaQuery\Result\PostQueryInterface; + +/** @implements IteratorAggregate */ +final class Articles implements PostQueryInterface, IteratorAggregate, Countable +{ + /** @param list
$rows */ + public function __construct(public readonly array $rows) {} + + public static function fromContext(PostQueryContext $context): static + { + /** @var list
$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, + )); + } + + /** @return ArrayIterator */ + public function getIterator(): ArrayIterator { return new ArrayIterator($this->rows); } + public function count(): int { return count($this->rows); } +} + +interface ArticleRepository +{ + /** @return Articles
*/ + #[DbQuery('article_list')] + public function list(): Articles; +} +``` + +Callers get `$articles->published()->totalWordCount()` — domain logic about the result set lives on the type, not scattered across services. `IteratorAggregate` / `Countable` give the wrapper standard "feels like an array" ergonomics. To compose a richer base, wrap a Laravel / Illuminate / Doctrine `Collection` via a property the same way. + +`$rows` shape is determined by what the framework hands the wrapper: + +- `@return Articles
` docblock or `factory:` attribute → entity instances. +- Neither declared → associative arrays. +- DML statement → `[]` (no fetch happens). + +`$rows === []` therefore means either "DML, didn't fetch" or "SELECT, no matches" — pick a result class scoped to one or the other rather than trying to handle both shapes. + +**Generic base for reuse across repositories:** + +Lift the entity out as a type variable when several repositories want the same shape with different entities. Psalm and PHPStan propagate the parameter through `foreach`, `$rows[N]`, and `iterator_to_array(...)`: + +```php +/** + * @template T + * @implements IteratorAggregate + */ +abstract class TypedRows implements PostQueryInterface, IteratorAggregate, Countable +{ + /** @param list $rows */ + public function __construct(public readonly array $rows) {} + + public static function fromContext(PostQueryContext $context): static + { + /** @var list $rows */ + $rows = $context->rows; + + return new static($rows); + } + + /** @return ArrayIterator */ + public function getIterator(): ArrayIterator { return new ArrayIterator($this->rows); } + public function count(): int { return count($this->rows); } + public function isEmpty(): bool { return $this->rows === []; } +} + +/** @extends TypedRows
*/ +final class Articles extends TypedRows +{ + public function published(): self { /* domain operations on Article rows */ } + public function totalWordCount(): int { /* ... */ } +} + +/** @extends TypedRows */ +final class Users extends TypedRows {} +``` + +`@extends TypedRows
` carries `Article` through to every site that inspects the rows — `$articles->rows[0]->title`, `foreach ($articles as $a) { $a->wordCount; }`, and any derived method on the base. The framework still hands `$context->rows` as `array`; the narrow happens at the `@var list` line in `fromContext()`, and from that point on the static analyser honours the parameter. Runtime is identical to the single-type wrapper above — PHP has no native generics, so this is a static-analysis claim, not a runtime check. + **Constructor Property Promotion (Recommended):** Use constructor property promotion for type-safe, immutable entities: @@ -565,7 +671,7 @@ class CustomRepository - `getRow($queryId, $params)` - Single row - `getRowList($queryId, $params)` - Multiple rows - `exec($queryId, $params)` - Execute without result -- `execPostQuery($queryId, $params, $postQueryClass)` - Execute DML and build a typed result via a `PostQueryInterface` class (e.g. `AffectedRows`, `InsertedRow`, or a custom class) +- `execPostQuery($queryId, $params, $postQueryClass, $fetch = null)` - Execute a SQL statement (SELECT or DML) and build a typed result via a `PostQueryInterface` class (e.g. `AffectedRows`, `InsertedRow`, a typed collection wrapper, or any custom class) - `getCount($queryId, $params)` - Total row count (for pagination) - `getStatement()` - Get PDO statement - `getPages()` - Get paginated results diff --git a/src/Result/PostQueryInterface.php b/src/Result/PostQueryInterface.php index f22c6e0..d961a2a 100644 --- a/src/Result/PostQueryInterface.php +++ b/src/Result/PostQueryInterface.php @@ -5,14 +5,16 @@ namespace Ray\MediaQuery\Result; /** - * Return-type contract for DML results built from post-execution PDO state. + * Return-type contract for results built after a query executes. * * Any class implementing this interface can be declared as the return type of - * a `#[DbQuery]` method. The DbQueryInterceptor detects the interface via - * `is_subclass_of` and calls the static {@see self::fromContext()} factory with - * a {@see PostQueryContext} holding the executed statement, its connection, - * and the resolved parameter values. Each result class owns the logic that - * turns that context into its own shape. + * a `#[DbQuery]` method, for either a DML statement (e.g. {@see AffectedRows}, + * {@see InsertedRow}) or a SELECT (a typed collection wrapping the hydrated + * rows). The DbQueryInterceptor detects the interface via `is_subclass_of` and + * calls the static {@see self::fromContext()} factory with a + * {@see PostQueryContext} holding the executed statement, its connection, the + * resolved parameter values, and — for SELECT — the hydrated rows. Each result + * class owns the logic that turns that context into its own shape. */ interface PostQueryInterface { diff --git a/src/SqlQueryInterface.php b/src/SqlQueryInterface.php index 6c4120a..b28ea86 100644 --- a/src/SqlQueryInterface.php +++ b/src/SqlQueryInterface.php @@ -71,6 +71,8 @@ public function exec(string $sqlId, array $values = [], FetchInterface|null $fet * * @param array $values * @param class-string $postQueryClass + * @param FetchInterface|null $fetch Strategy used to hydrate SELECT rows. Pass + * null for DML or to receive associative arrays. * * @return T * diff --git a/tests/DbQuerySelectPostQueryTest.php b/tests/DbQuerySelectPostQueryTest.php index ea66f4f..6c49618 100644 --- a/tests/DbQuerySelectPostQueryTest.php +++ b/tests/DbQuerySelectPostQueryTest.php @@ -9,13 +9,12 @@ use PHPUnit\Framework\TestCase; use Ray\AuraSqlModule\AuraSqlModule; use Ray\Di\Injector; -use Ray\MediaQuery\Entity\Article; use Ray\MediaQuery\Queries\ArticlesInterface; use Ray\MediaQuery\Result\Articles; use function dirname; use function file_get_contents; -use function usort; +use function iterator_to_array; class DbQuerySelectPostQueryTest extends TestCase { @@ -46,15 +45,12 @@ public function testReturnsArticlesWrapperWithAssocRows(): void $result = $repo->listAssoc(); $this->assertInstanceOf(Articles::class, $result); - /** @var list $rows */ - $rows = $result->rows; - usort($rows, static fn (array $a, array $b): int => $a['id'] <=> $b['id']); $this->assertSame( [ ['id' => '1', 'title' => 'run'], ['id' => '2', 'title' => 'walk'], ], - $rows, + $result->rows, ); } @@ -66,14 +62,54 @@ public function testReturnsArticlesWrapperWithHydratedEntities(): void $this->assertInstanceOf(Articles::class, $result); $this->assertCount(2, $result->rows); - /** @var list
$rows */ $rows = $result->rows; - usort($rows, static fn (Article $a, Article $b): int => $a->id <=> $b->id); + // The `@return Articles
` declaration carries `Article` through to + // `$rows[0]`, so `->id` / `->title` are statically typed without a cast. $this->assertSame('1', $rows[0]->id); $this->assertSame('run', $rows[0]->title); + $this->assertSame('2', $rows[1]->id); + $this->assertSame('walk', $rows[1]->title); + } + + public function testWrapperExposesCountableAndIteratorAggregate(): void + { + $repo = $this->injector->getInstance(ArticlesInterface::class); + $result = $repo->listHydrated(); + + $this->assertCount(2, $result); + $this->assertFalse($result->isEmpty()); + + // `iterator_to_array(Articles
)` yields `array`. + $iterated = iterator_to_array($result, false); + $this->assertSame($result->rows, $iterated); + } + + public function testFactoryAttributeHydratesViaPostQueryPath(): void + { + $repo = $this->injector->getInstance(ArticlesInterface::class); + $result = $repo->listViaFactory(); + $this->assertInstanceOf(Articles::class, $result); + $this->assertCount(2, $result->rows); + + $rows = $result->rows; + + // `@return Articles` carries through here too. + $this->assertSame('1', $rows[0]->id); + $this->assertSame('run', $rows[0]->title); $this->assertSame('2', $rows[1]->id); $this->assertSame('walk', $rows[1]->title); } + + public function testEmptySelectStillReturnsArticlesWrapper(): void + { + $repo = $this->injector->getInstance(ArticlesInterface::class); + $result = $repo->listEmpty(); + + $this->assertInstanceOf(Articles::class, $result); + $this->assertSame([], $result->rows); + $this->assertCount(0, $result); + $this->assertTrue($result->isEmpty()); + } } diff --git a/tests/Fake/Queries/ArticlesInterface.php b/tests/Fake/Queries/ArticlesInterface.php index 7a7dd4b..ab01ac6 100644 --- a/tests/Fake/Queries/ArticlesInterface.php +++ b/tests/Fake/Queries/ArticlesInterface.php @@ -6,14 +6,18 @@ use Ray\MediaQuery\Annotation\DbQuery; use Ray\MediaQuery\Entity\Article; +use Ray\MediaQuery\Entity\TodoConstruct; +use Ray\MediaQuery\Factory\TodoEntityFactory; use Ray\MediaQuery\Result\Articles; interface ArticlesInterface { /** * Rows come back as associative arrays (no entity hydration). + * + * @return Articles> */ - #[DbQuery('todo_list')] + #[DbQuery('article_list')] public function listAssoc(): Articles; /** @@ -21,6 +25,23 @@ public function listAssoc(): Articles; * * @return Articles
*/ - #[DbQuery('todo_list')] + #[DbQuery('article_list')] public function listHydrated(): Articles; + + /** + * Rows come back as `TodoConstruct` instances via the `factory:` attribute. + * + * @return Articles + */ + #[DbQuery('article_list', factory: TodoEntityFactory::class)] + public function listViaFactory(): Articles; + + /** + * SELECT that matches no rows — `$rows` is `[]`, but the wrapper is still + * an `Articles`, not null. + * + * @return Articles> + */ + #[DbQuery('article_list_empty')] + public function listEmpty(): Articles; } diff --git a/tests/Fake/Result/Articles.php b/tests/Fake/Result/Articles.php index a555ec0..6094a5b 100644 --- a/tests/Fake/Result/Articles.php +++ b/tests/Fake/Result/Articles.php @@ -4,18 +4,33 @@ namespace Ray\MediaQuery\Result; +use ArrayIterator; +use Countable; +use IteratorAggregate; use Override; +use function count; + /** - * SELECT-path PostQuery result: wraps the hydrated rows as a typed collection. + * Test fake — not part of the public API. + * + * SELECT-path PostQuery result: a typed collection wrapper around the rows + * pre-hydrated by the framework. * - * Callers that declare `Articles` as their return type receive the rows via - * `$context->rows`, pre-hydrated by the framework (entity instances when an - * entity is configured, associative arrays otherwise). + * The generic parameter `T` is the row shape — typically an entity class + * (`Articles
`) when `@return Articles` or a `factory:` + * attribute resolves to one, or `array` for the assoc-array + * fallback. The framework still hands `$context->rows` as `array`; + * the wrapper narrows it to `list` based on the caller's declaration. PHP + * has no runtime generics, so the narrow is a docblock claim — Psalm and + * PHPStan honour it through to `foreach`, `->rows[0]`, and derived methods. + * + * @template T + * @implements IteratorAggregate */ -final class Articles implements PostQueryInterface +final class Articles implements PostQueryInterface, IteratorAggregate, Countable { - /** @param array $rows */ + /** @param list $rows */ public function __construct( public readonly array $rows, ) { @@ -24,6 +39,27 @@ public function __construct( #[Override] public static function fromContext(PostQueryContext $context): static { - return new static($context->rows); + /** @var list $rows */ + $rows = $context->rows; + + return new static($rows); + } + + /** @return ArrayIterator */ + #[Override] + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->rows); + } + + #[Override] + public function count(): int + { + return count($this->rows); + } + + public function isEmpty(): bool + { + return $this->rows === []; } } diff --git a/tests/sql/article_list.sql b/tests/sql/article_list.sql new file mode 100644 index 0000000..dcd14b1 --- /dev/null +++ b/tests/sql/article_list.sql @@ -0,0 +1,3 @@ +SELECT id, title + FROM todo + ORDER BY id diff --git a/tests/sql/article_list_empty.sql b/tests/sql/article_list_empty.sql new file mode 100644 index 0000000..e3f7dbf --- /dev/null +++ b/tests/sql/article_list_empty.sql @@ -0,0 +1,3 @@ +SELECT id, title + FROM todo + WHERE 1 = 0 From 86ea8c5c8f4b0c3e171156f9eab861fd39ccb8ea Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 6 May 2026 22:04:10 +0900 Subject: [PATCH 4/5] Document DML + SELECT pattern for PostQueryInterface results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 === []. --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index 4c30ea8..349c6ea 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,42 @@ final class Users extends TypedRows {} `@extends TypedRows
` carries `Article` through to every site that inspects the rows — `$articles->rows[0]->title`, `foreach ($articles as $a) { $a->wordCount; }`, and any derived method on the base. The framework still hands `$context->rows` as `array`; the narrow happens at the `@var list` line in `fromContext()`, and from that point on the static analyser honours the parameter. Runtime is identical to the single-type wrapper above — PHP has no native generics, so this is a static-analysis claim, not a runtime check. +**Multi-statement SQL — DML + SELECT in one method:** + +`PostQueryInterface` dispatches based on the *last* executed statement, so a single SQL file can run a DML and then expose its result via a trailing SELECT: + +```sql +-- create_article.sql +INSERT INTO articles (title, body) VALUES (:title, :body); +SELECT * FROM articles WHERE id = last_insert_rowid(); +``` + +```php +final class CreatedArticle implements PostQueryInterface +{ + public function __construct(public readonly Article $article) {} + + public static function fromContext(PostQueryContext $context): static + { + /** @var list
$rows */ + $rows = $context->rows; + + return new static($rows[0]); + } +} + +interface ArticleRepository +{ + /** @return CreatedArticle */ + #[DbQuery('create_article')] + public function create(string $title, string $body): CreatedArticle; +} +``` + +The framework runs both statements in order. The last statement is a SELECT, so `$context->rows` carries its hydrated result — letting a single repository method express "execute and return a typed view of the affected row" without driver-specific `RETURNING`. The same shape rules apply: declare `@return CreatedArticle` (or a generic wrapper) and the trailing SELECT is hydrated to entities; omit it and `$context->rows` arrives as associative arrays. + +`$context->rows === []` therefore means "the last statement was DML" or "the last statement was a SELECT that matched nothing" — the distinction is determined by the SQL file you wrote, so each result class is naturally scoped to one of those. + **Constructor Property Promotion (Recommended):** Use constructor property promotion for type-safe, immutable entities: From 06499a4084b835e418892cc288f5f362338c91e0 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Wed, 6 May 2026 22:13:22 +0900 Subject: [PATCH 5/5] Address CodeRabbit review on PR #89 - 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. --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 349c6ea..d1b6690 100644 --- a/README.md +++ b/README.md @@ -422,11 +422,13 @@ final class Users extends TypedRows {} `PostQueryInterface` dispatches based on the *last* executed statement, so a single SQL file can run a DML and then expose its result via a trailing SELECT: ```sql --- create_article.sql +-- create_article.sql (SQLite — adjust the second statement per driver) INSERT INTO articles (title, body) VALUES (:title, :body); SELECT * FROM articles WHERE id = last_insert_rowid(); ``` +The `last_insert_rowid()` call is SQLite-specific. On other drivers, use the equivalent — e.g. `LAST_INSERT_ID()` on MySQL, or fold the SELECT into the INSERT via `INSERT ... RETURNING *` on PostgreSQL / MariaDB / SQLite ≥ 3.35. + ```php final class CreatedArticle implements PostQueryInterface { @@ -707,7 +709,7 @@ class CustomRepository - `getRow($queryId, $params)` - Single row - `getRowList($queryId, $params)` - Multiple rows - `exec($queryId, $params)` - Execute without result -- `execPostQuery($queryId, $params, $postQueryClass, $fetch = null)` - Execute a SQL statement (SELECT or DML) and build a typed result via a `PostQueryInterface` class (e.g. `AffectedRows`, `InsertedRow`, a typed collection wrapper, or any custom class) +- `execPostQuery($queryId, $params, $postQueryClass, FetchInterface|null $fetch = null)` - Execute a SQL statement (SELECT or DML) and build a typed result via a `PostQueryInterface` class (e.g. `AffectedRows`, `InsertedRow`, a typed collection wrapper, or any custom class). When `$fetch` is supplied, SELECT rows arrive on the context already hydrated to that strategy's shape. - `getCount($queryId, $params)` - Total row count (for pagination) - `getStatement()` - Get PDO statement - `getPages()` - Get paginated results