Repository navigation
Fix the ResultSet iterator contract and stop discarding object rows - #200
Closed
simon-mundy wants to merge 3 commits into
Closed
simon-mundy wants to merge 3 commits into
simon-mundy wants to merge 3 commits into
Conversation
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 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.
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 Closing rather than reworking, so this description stays as the record of what a major can pick up. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Takes
src/ResultSetfrom 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.mago lint,mago analyzeandmago format --checkare clean.Object rows were being discarded
AbstractResultSet::current()ended withis_array($data) ? $data : null. The method declaresarray|object|null, so objects were meant through. That guard exists to turnfalse, meaning the results are exhausted, intonull, and it took objects with it.Pdo\Result::isBuffered()is alwaysfalse, so a PDO result never takes thebuffer === -1path that returns the row untouched, and the guard saw every row.Under
FETCH_OBJandFETCH_LAZYevery row becamenull. No exception, no warning, no data.FETCH_BOUNDis unchanged on purpose: that mode carries its row in variables bound by reference and returns onlytrue, sonullis 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 uncaughtTypeErrorfromcount().HydratingResultSet::toArray()handed non-objects to the hydrator. The@throwstag promised an exception that was never raised; it is now.ResultSet::current()andRowPrototypeResultSet::current()declared return types that excluded objects the parent can return.count()returnednullwhilecount($resultSet)returned0, against theCountablecontract. It returnsintnow, and the0is deliberately not memoised so acount()beforeinitialize()cannot poison the cache.getObjectPrototype()declared?objectbut delegates to a method that cannot return null.The iterator contract
$dataSourcenarrows fromIterator|IteratorAggregate|ResultInterface|nullto?Iterator.ResultInterfacealready extendsIteratorandinitialize()already unwrapped aggregates, so the union was never accurate. Both@phpstan-ignore assign.propertyTypesuppressions go with it.initialize()now walks a chain ofIteratorAggregaterather than callinggetIterator()once, since that method returnsTraversableand may hand back another aggregate. A chain that never bottoms out in anIteratoris 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 onnull.prefer-self-return-type is retired
The three capability interfaces now extend
ResultSetInterface. Every setter returnedResultSetInterface&XInterfaceonly because they did not, andselfcannot appear in an intersection type. The return types are now plainself.That matters beyond this component:
prefer-self-return-typerewroteResultSetInterface&ArrayObjectResultSetInterfacetoResultSetInterface&self, which does not parse. It was the reasonmago lint --fixwas unsafe to run againstsrc. It is now safe.Sealing
TemporaryResultSetextends the abstract base rather thanResultSet; the adapter only ever asks forResultSetInterface. With nothing left subclassing them, the four concrete result sets and both exception classes arefinal, andEventFeatureTestdoubles 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
IteratorAggregatewhosegetIterator()returned itself and expected that to be stored, which is the bug written down as a contract. The other expectednullrather than0from an uncountable data source.ResultSetFetchModeTestis new. It drives a real SQLite result through the PDO driver across every entry inResult::VALID_FETCH_MODES: nine modes return rows,FETCH_BOUNDreturnsnullwhile its bound columns fill, and three throw. A singleFETCH_OBJcase would have missedFETCH_LAZYentirely.Raised separately
The three modes that throw are adapter defects, not result set ones:
Result::setFetchMode()takes only an int, soFETCH_CLASSandFETCH_INTOcan never receive the class name or target object PDO needs.VALID_FETCH_MODESlistsFETCH_FUNC, which PDO allows only infetchAll(), and two bit flags forFETCH_CLASSthat are meaningless alone.The 20 that remain
Nine are the
mixedfamily that follows from a row beingmixed, five are complexity metrics, three are deferred to RowGateway along withRowPrototypeInterface, two are array shapes no truthful annotation can narrow, and one is a Mago stub that treatsDOMNodeListand its kin asTraversablebut notIterator.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, orfalse. The only truthful annotation isarray<array-key, mixed>, which is identical to barearray, 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 onlint-baseline.tomlandanalyzer-baseline.toml. Regenerate after rebasing rather than merging those files by hand.