Skip to content

Introduce AffectedRows return type for DML queries #84

@koriym

Description

@koriym

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 Usernew 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 Collectionsilluminate/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 Collectionsdoctrine/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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions