Skip to content

Honour ResultSetReturnType::Prototype in ResultSet::current() - #202

Closed
simon-mundy wants to merge 5 commits into
php-db:0.6.xfrom
simon-mundy:fix/resultset-prototype-return-type
Closed

simon-mundy wants to merge 5 commits into
php-db:0.6.xfrom
simon-mundy:fix/resultset-prototype-return-type

Conversation

@simon-mundy

Copy link
Copy Markdown
Member

Branched from fix/resultset-object-rows (#201), which this depends on for the current() body it modifies. That branch is not on this repo, so GitHub cannot use it as a base and this diff includes #201's four commits. The commit to review here is the last one, Honour ResultSetReturnType::Prototype. Merge #201 first and this reduces to it.

ResultSetReturnType::Prototype was added in f394207 with no consumer. Nothing in the codebase read it, so selecting it silently gave you Array behaviour:

              getReturnType()   current()      current()
                                before         after
ArrayObject   ArrayObject       ArrayObject    ArrayObject
Array         Array             array          array
Prototype     Prototype         array          ArrayObject

The constructor accepted it, getReturnType() reported it back correctly, and current() tested only for ArrayObject and let everything else fall through to return $data. Every observable signal said it worked, which is what made it hard to spot.

Prototype is the successor name for ArrayObject. It follows the rename already in flight in this class — TYPE_ARRAYOBJECT giving way to the enum, setArrayObjectPrototype() to setRowPrototype(), getArrayObjectPrototype() to getRowPrototype() — and for ResultSet the ArrayObject is the row prototype. Both cases now fill it.

Resolved once, not per row

The return type cannot change after construction, so the decision belongs there:

private readonly ResultSetReturnType $returnType;

private readonly bool $fillsRowPrototype;

public function __construct(...)
{
    $this->returnType        = is_string($returnType) ? ResultSetReturnType::from($returnType) : $returnType;
    $this->fillsRowPrototype = match ($this->returnType) {
        ResultSetReturnType::ArrayObject, ResultSetReturnType::Prototype => true,
        ResultSetReturnType::Array => false,
    };
}

current() runs once per row, so evaluating this there was repeated work for an answer fixed at construction. Per-row cost is now a bool property read, below the ResultSetReturnType::ArrayObject === $this->returnType comparison it replaces.

Both properties are readonly. That costs nothing today — $returnType was already private and assigned once — and it stops a later setReturnType() leaving the derived flag stale, which is the one real hazard in caching derived state.

The exhaustive match is the actual fix

Wiring Prototype is the symptom; the defect is that an identity check against one case lets every other case vanish into the fall-through. A match with no default arm closes that:

$ new ResultSet(ResultSetReturnType::Hydrated)      # a case added without a decision
UnhandledMatchError: Unhandled match case of type PhpDb\ResultSet\ResultSetReturnType

It fails at construction, before any row is touched, rather than part-way through an iteration. returnTypeProvider() covers every case and asserts its own completeness against ResultSetReturnType::cases(), so the same omission also fails the suite:

$ vendor/bin/phpunit --filter everyReturnTypeYieldsItsRowType
every ResultSetReturnType case needs a row type asserted here
Failed asserting that 3 is identical to 4.

Both were verified by temporarily adding a fourth case.

Compatibility

No signature changes. Prototype stops behaving as Array, which is observable only for a caller relying on that accident; nothing in the repo referenced the case.

ResultSetReturnType::ArrayObject is not deprecated here, though completing the rename would suggest it. It is the constructor's default, so deprecating it means either leaving the default pointing at a deprecated case or changing the default to Prototype — and that changes what getReturnType() returns for a default-constructed ResultSet, which returnTypeIsObjectByDefault asserts. Worth doing, but a separate decision from making the case work.

Before After
PHPUnit 2040 tests, 3965 assertions 2043 tests, 3968 assertions

mago lint, mago analyze and mago format --check are clean, and src/ResultSet stays at 100% line coverage.

AbstractResultSet::current() ended with `is_array($data) ? $data : null`
while declaring `array|object|null`. The guard exists to turn `false`,
meaning the results are exhausted, into `null`, and it took objects with
it. Pdo\Result::isBuffered() is always false, so a PDO result never takes
the `buffer === -1` path that returns the row untouched, and the guard saw
every row. Under FETCH_OBJ and FETCH_LAZY every row became null, with no
exception and no warning.

FETCH_BOUND is unchanged on purpose: that mode carries its row in
variables bound by reference and returns only true, so null is correct.

Five further defects followed from the same fact, that a row need not be
an array:

- initialize() measured the first element of an array rather than the
  array, so an array of objects raised an uncaught TypeError from count().
- initialize() called getIterator() once, but that method returns
  Traversable and may hand back another aggregate. It now walks the chain
  and rejects one that never bottoms out in an Iterator.
- The iteration methods reached the data source directly, so calling them
  before initialize() raised an Error. They now report an uninitialised
  result set.
- HydratingResultSet::toArray() handed non-objects to the hydrator. The
  @throws tag promised an exception that was never raised; it is now.
- ResultSet::current() and RowPrototypeResultSet::current() declared
  return types that excluded objects the parent can return.

ResultSetFetchModeTest drives a real SQLite result through the PDO driver
across every entry in Result::VALID_FETCH_MODES: nine modes return rows,
FETCH_BOUND returns null while its bound columns fill, and three throw. A
single FETCH_OBJ case would have missed FETCH_LAZY entirely. The three
that throw are adapter defects raised separately as php-db#198 and php-db#199.

src/ResultSet goes from 90 baselined findings to 36. The remainder are
the mixed family that follows from a row being mixed, complexity metrics,
array shapes no truthful annotation can narrow, and a Mago stub that
treats DOMNodeList and its kin as Traversable but not Iterator.

No public signature is narrowed and nothing is sealed, so the change is
binary compatible for downstream consumers.
The previous commit widened ResultSet::current() to array|object|null and
RowPrototypeResultSet::current() likewise, so that object rows could pass
through untouched. That made both declarations weaker than the contract
each class actually offers: a ResultSet hands back ArrayObject, and a
RowPrototypeResultSet hands back its prototype.

Both keep their original narrow return types. A row that already
satisfies the declared type is handed back untouched, preserving
instance identity. Any other object row is reduced to an array and used
to fill a clone of the prototype, so FETCH_OBJ now yields an ArrayObject
rather than a stdClass.

HydratingResultSet did the same thing behind a ?object declaration: it
tested is_array() and returned null for everything else, so object rows
were still discarded there. It now hydrates them on the same terms.

AbstractResultSet::rowToArray() performs the reduction. A row carrying
its values as elements rather than properties, ArrayObject being the
common case, is read through its iterator. Everything else goes through
get_object_vars(), which sees only public properties from outside the
row's scope; a plain cast would instead yield mangled keys for anything
private.

A row that exposes nothing either way is rejected rather than emptied.
PDORow is the case that matters: FETCH_LAZY resolves columns through
__get(), so both get_object_vars() and an array cast return an empty
array. Converting it would drop every column, which is a quieter version
of the defect this branch set out to fix, so FETCH_LAZY now raises
RuntimeException::forUnconvertibleRow() through the three result sets
that fill a prototype. It continues to work on AbstractResultSet, which
promises nothing about the row's type.

HydratingResultSet::current() gained enough work to cross the halstead
threshold, so the conversion and hydration move into hydrateRow().

src/ResultSet is at 100% line coverage and 35 baselined findings.
Every array row fills the prototype and every object row is either
already a prototype or reduced to an array that does, so the method
could only ever return a prototype or null. The declared `array` arm was
unreachable.

This is the one return type in the component that narrows against 0.6.x.
A subclass overriding current() with the wider declaration would no
longer satisfy covariance.
ArrayResultSet had no current() override, so it inherited
array|object|null from the base class and could hand back an object
despite being the result set that exists to yield arrays. It now
declares ?array and reduces an object row through rowToArray(), on the
same terms as the three prototype-filling result sets.

Five cases cover the method: an array row, an object row, a row carrying
its values as elements, a row that exposes nothing, and an exhausted
data source.

Also removes the inline comments and docblock prose added earlier in
this branch. The @throws and @return tags stay, since mago analyze
reports unhandled-thrown-type and imprecise-type without them.
The case was added in f394207 with no consumer. Nothing read it, so a
caller selecting it silently got Array behaviour: the constructor
accepted it, getReturnType() reported it, current() checked only for
ArrayObject and let everything else fall through to the raw row. Every
observable signal said it worked.

Prototype is the successor name for ArrayObject. It follows the rename
already in flight elsewhere in the class, where TYPE_ARRAYOBJECT gives
way to the enum and setArrayObjectPrototype() to setRowPrototype(); for
ResultSet the ArrayObject and the row prototype are the same object.
Both cases now fill the prototype.

The decision is resolved once in the constructor rather than on each
current() call, since the return type cannot change after construction.
Both properties are readonly so that a later setter cannot leave the
derived flag stale. Per-row cost drops to a bool read, below the enum
comparison it replaces.

The match is exhaustive. A fourth case with no decision here raises
UnhandledMatchError at construction, before any row is touched, rather
than falling through current() unnoticed. returnTypeProvider() covers
every case and asserts its own completeness against
ResultSetReturnType::cases(), so the same omission fails the suite too.

Stacked on fix/resultset-object-rows, which current() depends on.
@codecov

codecov Bot commented Sep 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@simon-mundy

Copy link
Copy Markdown
Member Author

Folded into #201 rather than stacked; this was never meant to be a separate pull request.

@simon-mundy
simon-mundy deleted the fix/resultset-prototype-return-type branch September 30, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant