Skip to content

Fix the ResultSet iterator contract and stop discarding object rows - #200

Closed
simon-mundy wants to merge 3 commits into
php-db:0.6.xfrom
simon-mundy:fix/resultset-iterator-contract
Closed

simon-mundy wants to merge 3 commits into
php-db:0.6.xfrom
simon-mundy:fix/resultset-iterator-contract

Conversation

@simon-mundy

Copy link
Copy Markdown
Member

Takes src/ResultSet from 90 Mago findings to 20, and fixes six defects found along the way. Every one of them followed from the same fact: a row reaching the result set need not be an array.

Before After
Lint 25 4
Analyzer 65 16
PHPUnit 1988 tests, 3901 assertions 2017 tests, 3932 assertions

mago lint, mago analyze and mago format --check are clean.

Object rows were being discarded

AbstractResultSet::current() ended with is_array($data) ? $data : null. The method declares array|object|null, so objects were meant through. That 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. No exception, no warning, no data.

FETCH_LAZY   before: [null, null]      after: [PDORow, PDORow]
FETCH_OBJ    before: [null, null]      after: [stdClass, stdClass]
FETCH_BOUND  before: [null, null]      after: [null, null]

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

The rest of the defects

  • initialize() measured the first element of an array rather than the array, so an array of objects raised an uncaught TypeError from count().
  • 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.
  • count() returned null while count($resultSet) returned 0, against the Countable contract. It returns int now, and the 0 is deliberately not memoised so a count() before initialize() cannot poison the cache.
  • getObjectPrototype() declared ?object but delegates to a method that cannot return null.

The iterator contract

$dataSource narrows from Iterator|IteratorAggregate|ResultInterface|null to ?Iterator. ResultInterface already extends Iterator and initialize() already unwrapped aggregates, so the union was never accurate. Both @phpstan-ignore assign.propertyType suppressions go with it.

initialize() now walks a chain of IteratorAggregate rather than calling getIterator() once, since that method returns Traversable and may hand back another aggregate. A chain that never bottoms out in an Iterator is rejected rather than stored. The iteration methods reach the data source through an accessor that reports an uninitialised result set instead of calling a method on null.

prefer-self-return-type is retired

The three capability interfaces now extend ResultSetInterface. Every setter returned ResultSetInterface&XInterface only because they did not, and self cannot appear in an intersection type. The return types are now plain self.

That matters beyond this component: prefer-self-return-type rewrote ResultSetInterface&ArrayObjectResultSetInterface to ResultSetInterface&self, which does not parse. It was the reason mago lint --fix was unsafe to run against src. It is now safe.

Sealing

TemporaryResultSet extends the abstract base rather than ResultSet; the adapter only ever asks for ResultSetInterface. With nothing left subclassing them, the four concrete result sets and both exception classes are final, and EventFeatureTest doubles the interface it actually needs rather than a now-final class.

Tests

Two existing tests asserted the old behaviour and are corrected. One mocked an IteratorAggregate whose getIterator() returned itself and expected that to be stored, which is the bug written down as a contract. The other expected null rather than 0 from an uncountable data source.

ResultSetFetchModeTest is new. It 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.

Raised separately

The three modes that throw are adapter defects, not result set ones:

The 20 that remain

Nine are the mixed family that follows from a row being mixed, five are complexity metrics, three are deferred to RowGateway along with RowPrototypeInterface, two are array shapes no truthful annotation can narrow, and one is a Mago stub that treats DOMNodeList and its kin as Traversable but not Iterator.

Worth noting that array<string, mixed> is not a safe shape for a row here. The fetch mode is caller selectable, so a row can be string keyed, integer keyed, both, an object, true, or false. The only truthful annotation is array<array-key, mixed>, which is identical to bare array, so those findings survive any honest fix.

Merge note

Branched from 0.6.x, so the regenerated baselines do not include #197. The two will conflict on lint-baseline.toml and analyzer-baseline.toml. Regenerate after rebasing rather than merging those files by hand.

Narrows $dataSource from Iterator|IteratorAggregate|ResultInterface|null
to ?Iterator. ResultInterface already extends Iterator, and initialize()
already unwrapped aggregates, so the union was never accurate. Both
phpstan-ignore suppressions go with it.

initialize() now walks a chain of IteratorAggregate rather than calling
getIterator() once, since that method returns Traversable and may hand
back another aggregate. A chain that never bottoms out in an Iterator is
rejected instead of stored. current(), next(), rewind() and valid() reach
the data source through an accessor that reports an uninitialised result
set rather than calling a method on null.

The three capability interfaces now extend ResultSetInterface. Every
setter returned ResultSetInterface&XInterface only because they did not,
and self cannot appear in an intersection type, so the return types are
now plain self. That retires prefer-self-return-type, which emitted
source that does not parse and made mago lint --fix unsafe against src.

TemporaryResultSet extends the abstract base rather than ResultSet: the
adapter only ever asks for ResultSetInterface. With nothing left
subclassing them, the four concrete result sets and both exceptions are
final, and EventFeatureTest doubles the interface it actually needs.

Five defects fall out of the same fact, that a row need not be an array.
The fetch mode is caller selectable across thirteen modes, so a row can
be a string keyed array, an integer keyed array, an object, true under
FETCH_BOUND, or false once the results are exhausted:

  - initialize() measured the first element of the array rather than the
    array, so an array of objects raised an uncaught TypeError
  - HydratingResultSet::toArray() handed non-objects to the hydrator; the
    throws tag promised an exception that was never raised
  - ResultSet::current() and RowPrototypeResultSet::current() declared
    return types that excluded objects the parent can return
  - count() returned null while count($resultSet) returned zero, against
    the Countable contract
  - getObjectPrototype() declared null but delegates to a method that
    cannot return it

Two tests asserted the old behaviour and are corrected: one mocked an
IteratorAggregate returning itself and expected it to be stored, the
other expected null rather than zero from an uncountable data source.

Of the twenty that remain, nine are the mixed family that follows from a
row being mixed, five are complexity metrics, three are deferred to
RowGateway with RowPrototypeInterface, two are array shapes no truthful
annotation can narrow, and one is a Mago stub that treats DOMNodeList and
its kin as Traversable but not Iterator.
AbstractResultSet::current() ended with is_array($data) ? $data : null.
The method declares array|object|null, so objects were meant through; the
guard is there to turn false, meaning the results are exhausted, into
null, and it took objects with it.

Because Pdo\Result::isBuffered() is always false, a PDO result never
takes the buffer === -1 path that returns the row untouched, so the guard
saw every row. Under FETCH_OBJ and FETCH_LAZY every row became null. No
exception, no warning, just no data.

FETCH_BOUND still yields null, which is right: that mode carries its row
in variables bound by reference and returns only true, so there is
nothing to hand back.

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:
FETCH_CLASS and FETCH_INTO because setFetchMode() takes only an int and
cannot pass the class name or target object PDO needs, and FETCH_FUNC
because PDO allows it only in fetchAll(). Those three are covered here so
the gap stays visible, and are raised separately against the adapter.
@codecov

codecov Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Patch coverage on the pull request sat at 63.93%. Most of the gap was
attribution rather than absent tests: CoversMethod restricts coverage to
the methods it names, and resolveIterator() and dataSource() were new, so
their lines were never credited even though every iteration runs them.
Both are now declared on the tests that exercise them.

The rest were genuinely untested, all of them paths this branch added:

  - current() and valid() on a result set that was never initialised, so
    forUninitialisedDataSource() is exercised rather than assumed
  - HydratingResultSet::toArray() over rows that cannot hydrate, which is
    the throw the docblock had always promised
  - the four new named constructors, following the convention already set
    by RuntimeExceptionTest

The throw for a data source that resolves to something other than an
Iterator is marked ignored, with the reasoning beside it. Userland cannot
implement Traversable without Iterator or IteratorAggregate, and the
internal classes that do are aggregates the loop above has unwrapped, so
nothing can reach it. It stays because the return type depends on it.

src/ResultSet is now 191 of 191 lines.
@simon-mundy

Copy link
Copy Markdown
Member Author

Superseded by #201, which ships the six defect fixes without the binary compatibility breaks.

Everything dropped is listed under "Deliberately not fixed" there: the Countable violation in count(), the sealing, the narrowed getDataSource() and $dataSource, getObjectPrototype(): ?object, the typed constants, and the interface re-parenting that retired prefer-self-return-type.

Closing rather than reworking, so this description stays as the record of what a major can pick up.

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