Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 145 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Entity>` 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<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,
));
}

/** @return ArrayIterator<int, Article> */
public function getIterator(): ArrayIterator { return new ArrayIterator($this->rows); }
public function count(): int { return count($this->rows); }
}

interface ArticleRepository
{
/** @return Articles<Article> */
#[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<Article>` 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<int, T>
*/
abstract class TypedRows implements PostQueryInterface, IteratorAggregate, Countable
{
/** @param list<T> $rows */
public function __construct(public readonly array $rows) {}

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

return new static($rows);
}

/** @return ArrayIterator<int, T> */
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<Article> */
final class Articles extends TypedRows
{
public function published(): self { /* domain operations on Article rows */ }
public function totalWordCount(): int { /* ... */ }
}

/** @extends TypedRows<User> */
final class Users extends TypedRows {}
```

`@extends TypedRows<Article>` 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<mixed>`; the narrow happens at the `@var list<T>` 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();
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<Article> $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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/DbQueryInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
12 changes: 10 additions & 2 deletions src/Result/PostQueryContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $values */
/**
* @param array<string, mixed> $values
* @param array<mixed> $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 = [],
) {
}
}
14 changes: 8 additions & 6 deletions src/Result/PostQueryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
7 changes: 6 additions & 1 deletion src/ReturnEntity.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
6 changes: 3 additions & 3 deletions src/SqlQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
22 changes: 15 additions & 7 deletions src/SqlQueryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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<string, mixed> $values
* @param class-string<T> $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;
Comment on lines 72 to +82
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.


/**
* Return the total row count for a SELECT. Used as the pagination denominator.
Expand Down
Loading
Loading