Summary
Introduce a dedicated AffectedRows return type for DML (INSERT / UPDATE / DELETE) queries, and use return-type-based dispatch instead of extending the type parameter. This is a refined alternative to #83 after discussion.
Refines: #83
Motivation
Currently Ray\MediaQuery has no clean way to obtain DML affected row counts. SqlQueryInterface exposes only getRow / getRowList, so callers cannot tell whether a DELETE actually removed a row without issuing an extra SELECT. This has been a long-standing pain point.
In #83 we considered extending type: 'row'|'row_list' with 'scalar'|'column'|'exists'|'affected_rows'. After discussion we concluded:
- The
type parameter's only real purpose is to disambiguate array (single row vs list of rows).
scalar / column / exists can be derived from an array return at the call site — they don't affect SQL execution path and don't need library-level dispatch.
affected_rows is fundamentally different: it requires a different execution path (PDOStatement::rowCount() instead of fetchAll), and the result has no natural representation as array. It genuinely needs library support.
Therefore the minimal and most consistent addition is a single return type: AffectedRows.
Proposed API
namespace Ray\MediaQuery\Result;
final class AffectedRows
{
public function __construct(
public readonly int $count,
public readonly ?string $lastInsertId = null,
) {
}
public function isAffected(): bool
{
return $this->count > 0;
}
}
Usage
interface ArticleRepositoryInterface
{
#[DbQuery('article_delete')]
public function delete(string $id): AffectedRows;
#[DbQuery('article_update')]
public function update(string $id, string $title): AffectedRows;
#[DbQuery('article_insert')]
public function create(string $title): AffectedRows;
}
$result = $repo->delete($id);
if (! $result->isAffected()) {
throw new NotFoundException($id);
}
$logger->info(\"deleted {$result->count} rows\");
// INSERT with auto-increment
$result = $repo->create('Hello');
$newId = $result->lastInsertId;
Dispatch rules
The interceptor inspects the PHP return type:
| Return type |
Behavior |
AffectedRows |
DML path: execute() + rowCount() (+ lastInsertId()) |
array |
Existing row_list behavior (unchanged) |
?Entity / Entity |
Existing single-row hydration (unchanged) |
Relation to the type parameter
type existed only to resolve the array ambiguity. With AffectedRows expressed via the return type, type is redundant for this case.
- If both are present and conflict, the return type wins and an
E_USER_DEPRECATED notice is emitted.
type is marked @deprecated in 1.x and scheduled for removal in 2.0.
Why not add Cell / ColumnList / Row / RowList?
These are result-shape transformations that a caller can perform trivially against an array return ($rows[0][0], array_column($rows, 0), etc.). They don't change the SQL execution path, so keeping them out of the library preserves loose coupling.
If a caller wants a richer collection API, they can wrap the returned array with any collection of their choice — generic or bespoke — and it stays decoupled from Ray.MediaQuery.
Backward compatibility
- Existing
array / array<Entity> / ?Entity / Entity return types: unchanged.
- Existing
type: 'row' | 'row_list': continues to work in 1.x, deprecated, removed in 2.0.
- New
AffectedRows usage is opt-in.
Scope of this issue
In scope:
- Add
Ray\MediaQuery\Result\AffectedRows value object.
- Add a DML execution path to
SqlQueryInterface (e.g. exec(string $id, array $values): array{count: int, lastInsertId: ?string}).
- Dispatch
AffectedRows return types in DbQueryInterceptor.
- Deprecate
DbQuery::$type in 1.x.
Out of scope:
- Collection types for
SELECT results (user-side concern).
- Removal of
DbQuery::$type (targeted for 2.0).
User-defined collection auto-wrap (follow-up consideration)
In addition to AffectedRows, we can support user-defined collection classes by convention — without the library owning any collection type.
Dispatch rule extension
If the return type is a class that implements Traversable (i.e. IteratorAggregate or Iterator), the interceptor fetches the row list and calls new $class($rowList).
| Return type |
Behavior |
array |
row_list of raw rows (unchanged) |
array<User> / factory: User |
row_list hydrated to User (unchanged) |
?User / User (non-Traversable class) |
single-row hydration (unchanged) |
UserCollection (implements Traversable) |
row_list → new UserCollection($rowList) (new) |
AffectedRows |
DML path (new, this issue) |
Detection basis: implements Traversable
- Entities / value objects don't implement
Traversable.
- Collections naturally do.
- No new marker interface needed; the built-in
Traversable is sufficient.
Usage
/** @return UserCollection */
#[DbQuery('users', factory: User::class)]
public function list(): UserCollection;
Internally: fetch row_list → hydrate each row to User → new UserCollection([$user, $user, ...]).
Constructor convention
The collection class must accept a single array argument in its constructor: __construct(array $items).
Constructor argument shape
The element type of the array passed to the constructor depends on whether hydration is configured:
| Configuration |
Constructor receives |
factory: User::class or @return Collection<User> |
array<User> (hydrated entities) |
| No factory, no PHPDoc element type |
array<array> (raw associative rows) |
Dispatch pipeline:
fetch row_list → array<array>
↓
factory / @return Collection<T> present?
yes → hydrate each row to T → array<T>
no → keep as is → array<array>
↓
new $returnClass($items)
The element type is the user's choice — a UserCollection of hydrated User objects, or a raw RowCollection of associative arrays.
Example collection (recommended baseline, not shipped by the library)
final class UserCollection implements IteratorAggregate, Countable, JsonSerializable
{
/** @param list<User> $items */
public function __construct(private readonly array $items) {}
public function getIterator(): ArrayIterator { return new ArrayIterator($this->items); }
public function count(): int { return count($this->items); }
public function jsonSerialize(): array { return $this->items; }
public function isEmpty(): bool { return $this->items === []; }
public function first(): ?User { return $this->items[0] ?? null; }
public function last(): ?User { return $this->items[array_key_last($this->items)] ?? null; }
public function toArray(): array { return $this->items; }
/** @return list<mixed> */
public function pluck(string $field): array { return array_column($this->items, $field); }
/** @return array<string|int, User> */
public function keyBy(string $field): array { return array_column($this->items, null, $field); }
/** @return array<string|int, list<User>> */
public function groupBy(string $field): array
{
$grouped = [];
foreach ($this->items as $item) {
$grouped[$item->{$field}][] = $item;
}
return $grouped;
}
/** @template U @param callable(User): U $fn @return list<U> */
public function map(callable $fn): array { return array_map($fn, $this->items); }
/** @param callable(User): bool $fn */
public function filter(callable $fn): self { return new self(array_values(array_filter($this->items, $fn))); }
}
This is just a reference shape. Users can bring Laravel Collections, Symfony's ArrayCollection, bespoke domain collections, or AI-generated ones — anything that satisfies implements Traversable and __construct(array $items).
Why this still fits the "library owns nothing" stance
The library ships no collection class. It only adds one rule to the interceptor: "if return type is Traversable, wrap". Coupling stays loose; users own their collection types.
Off-the-shelf collections that satisfy the convention
Any class that implements Traversable and accepts __construct(array $items) works out of the box. Known-good options:
Laravel Collections — illuminate/collections
use Illuminate\Support\Collection;
/** @return Collection<int, User> */
#[DbQuery('users', factory: User::class)]
public function list(): Collection;
- Rich API (
map / filter / pluck / keyBy / groupBy / chunk / reduce / 100+ methods)
- Can be used standalone without the Laravel framework
composer require illuminate/collections
Doctrine Collections — doctrine/collections
use Doctrine\Common\Collections\ArrayCollection;
/** @return ArrayCollection<int, User> */
#[DbQuery('users', factory: User::class)]
public function list(): ArrayCollection;
- Lightweight, smaller API (
map / filter / first / last / toArray)
- Battle-tested via Doctrine ORM
composer require doctrine/collections
Not directly compatible (different constructor signature, would need a thin wrapper):
ramsey/collection — constructor requires an explicit type argument
loophp/collection — uses static factories instead of a conventional constructor
This confirms the design intent: users can plug in established collections without Ray.MediaQuery owning a collection type.
Summary
Introduce a dedicated
AffectedRowsreturn type for DML (INSERT/UPDATE/DELETE) queries, and use return-type-based dispatch instead of extending thetypeparameter. This is a refined alternative to #83 after discussion.Refines: #83
Motivation
Currently
Ray\MediaQueryhas no clean way to obtain DML affected row counts.SqlQueryInterfaceexposes onlygetRow/getRowList, so callers cannot tell whether aDELETEactually removed a row without issuing an extraSELECT. This has been a long-standing pain point.In #83 we considered extending
type: 'row'|'row_list'with'scalar'|'column'|'exists'|'affected_rows'. After discussion we concluded:typeparameter's only real purpose is to disambiguatearray(single row vs list of rows).scalar/column/existscan be derived from anarrayreturn at the call site — they don't affect SQL execution path and don't need library-level dispatch.affected_rowsis fundamentally different: it requires a different execution path (PDOStatement::rowCount()instead offetchAll), and the result has no natural representation asarray. It genuinely needs library support.Therefore the minimal and most consistent addition is a single return type:
AffectedRows.Proposed API
Usage
Dispatch rules
The interceptor inspects the PHP return type:
AffectedRowsexecute()+rowCount()(+lastInsertId())arrayrow_listbehavior (unchanged)?Entity/EntityRelation to the
typeparametertypeexisted only to resolve thearrayambiguity. WithAffectedRowsexpressed via the return type,typeis redundant for this case.E_USER_DEPRECATEDnotice is emitted.typeis marked@deprecatedin1.xand scheduled for removal in2.0.Why not add
Cell/ColumnList/Row/RowList?These are result-shape transformations that a caller can perform trivially against an
arrayreturn ($rows[0][0],array_column($rows, 0), etc.). They don't change the SQL execution path, so keeping them out of the library preserves loose coupling.If a caller wants a richer collection API, they can wrap the returned
arraywith any collection of their choice — generic or bespoke — and it stays decoupled fromRay.MediaQuery.Backward compatibility
array/array<Entity>/?Entity/Entityreturn types: unchanged.type: 'row' | 'row_list': continues to work in1.x, deprecated, removed in2.0.AffectedRowsusage is opt-in.Scope of this issue
In scope:
Ray\MediaQuery\Result\AffectedRowsvalue object.SqlQueryInterface(e.g.exec(string $id, array $values): array{count: int, lastInsertId: ?string}).AffectedRowsreturn types inDbQueryInterceptor.DbQuery::$typein1.x.Out of scope:
SELECTresults (user-side concern).DbQuery::$type(targeted for2.0).User-defined collection auto-wrap (follow-up consideration)
In addition to
AffectedRows, we can support user-defined collection classes by convention — without the library owning any collection type.Dispatch rule extension
If the return type is a class that implements
Traversable(i.e.IteratorAggregateorIterator), the interceptor fetches the row list and callsnew $class($rowList).arrayarray<User>/factory: UserUser(unchanged)?User/User(non-Traversable class)UserCollection(implementsTraversable)new UserCollection($rowList)(new)AffectedRowsDetection basis:
implements TraversableTraversable.Traversableis sufficient.Usage
Internally: fetch row_list → hydrate each row to
User→new UserCollection([$user, $user, ...]).Constructor convention
The collection class must accept a single
arrayargument in its constructor:__construct(array $items).Constructor argument shape
The element type of the array passed to the constructor depends on whether hydration is configured:
factory: User::classor@return Collection<User>array<User>(hydrated entities)array<array>(raw associative rows)Dispatch pipeline:
The element type is the user's choice — a
UserCollectionof hydratedUserobjects, or a rawRowCollectionof associative arrays.Example collection (recommended baseline, not shipped by the library)
This is just a reference shape. Users can bring Laravel Collections, Symfony's
ArrayCollection, bespoke domain collections, or AI-generated ones — anything that satisfiesimplements Traversableand__construct(array $items).Why this still fits the "library owns nothing" stance
The library ships no collection class. It only adds one rule to the interceptor: "if return type is
Traversable, wrap". Coupling stays loose; users own their collection types.Off-the-shelf collections that satisfy the convention
Any class that implements
Traversableand accepts__construct(array $items)works out of the box. Known-good options:Laravel Collections —
illuminate/collectionsmap/filter/pluck/keyBy/groupBy/chunk/reduce/ 100+ methods)composer require illuminate/collectionsDoctrine Collections —
doctrine/collectionsmap/filter/first/last/toArray)composer require doctrine/collectionsNot directly compatible (different constructor signature, would need a thin wrapper):
ramsey/collection— constructor requires an explicit type argumentloophp/collection— uses static factories instead of a conventional constructorThis confirms the design intent: users can plug in established collections without Ray.MediaQuery owning a collection type.