diff --git a/README.md b/README.md index c33f0f7..d1b6690 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,150 @@ 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. + +**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 (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 +{ + 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: @@ -565,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)` - Execute DML and build a typed result via a `PostQueryInterface` class (e.g. `AffectedRows`, `InsertedRow`, or a 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 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/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/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..b28ea86 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,23 +56,30 @@ 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 + * count + last insert id, a typed collection wrapper, etc.). For SELECT + * 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 + * @param FetchInterface|null $fetch Strategy used to hydrate SELECT rows. Pass + * null for DML or to receive associative arrays. * * @return T * * @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..6c49618 --- /dev/null +++ b/tests/DbQuerySelectPostQueryTest.php @@ -0,0 +1,115 @@ + 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); + + $rows = $result->rows; + + // 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/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..ab01ac6 --- /dev/null +++ b/tests/Fake/Queries/ArticlesInterface.php @@ -0,0 +1,47 @@ +> + */ + #[DbQuery('article_list')] + public function listAssoc(): Articles; + + /** + * Rows come back as `Article` instances via docblock-driven entity hydration. + * + * @return Articles
+ */ + #[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 new file mode 100644 index 0000000..6094a5b --- /dev/null +++ b/tests/Fake/Result/Articles.php @@ -0,0 +1,65 @@ +`) 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, IteratorAggregate, Countable +{ + /** @param list $rows */ + public function __construct( + public readonly array $rows, + ) { + } + + #[Override] + public static function fromContext(PostQueryContext $context): static + { + /** @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