Repository navigation
Honour ResultSetReturnType::Prototype in ResultSet::current() - #202
Closed
simon-mundy wants to merge 5 commits into
Closed
simon-mundy wants to merge 5 commits into
simon-mundy wants to merge 5 commits into
Conversation
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 Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Member
Author
|
Folded into #201 rather than stacked; this was never meant to be a separate pull request. |
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.
ResultSetReturnType::Prototypewas added in f394207 with no consumer. Nothing in the codebase read it, so selecting it silently gave youArraybehaviour:The constructor accepted it,
getReturnType()reported it back correctly, andcurrent()tested only forArrayObjectand let everything else fall through toreturn $data. Every observable signal said it worked, which is what made it hard to spot.Prototypeis the successor name forArrayObject. It follows the rename already in flight in this class —TYPE_ARRAYOBJECTgiving way to the enum,setArrayObjectPrototype()tosetRowPrototype(),getArrayObjectPrototype()togetRowPrototype()— and forResultSetthe 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:
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 theResultSetReturnType::ArrayObject === $this->returnTypecomparison it replaces.Both properties are
readonly. That costs nothing today —$returnTypewas already private and assigned once — and it stops a latersetReturnType()leaving the derived flag stale, which is the one real hazard in caching derived state.The exhaustive match is the actual fix
Wiring
Prototypeis the symptom; the defect is that an identity check against one case lets every other case vanish into the fall-through. Amatchwith nodefaultarm closes that: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 againstResultSetReturnType::cases(), so the same omission also fails the suite:Both were verified by temporarily adding a fourth case.
Compatibility
No signature changes.
Prototypestops behaving asArray, which is observable only for a caller relying on that accident; nothing in the repo referenced the case.ResultSetReturnType::ArrayObjectis 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 toPrototype— and that changes whatgetReturnType()returns for a default-constructedResultSet, whichreturnTypeIsObjectByDefaultasserts. Worth doing, but a separate decision from making the case work.mago lint,mago analyzeandmago format --checkare clean, andsrc/ResultSetstays at 100% line coverage.