Skip to content

ResultSet Refactoring for Mago - #201

Merged
simon-mundy merged 17 commits into
php-db:0.6.xfrom
simon-mundy:fix/resultset-object-rows
Oct 3, 2026
Merged

simon-mundy merged 17 commits into
php-db:0.6.xfrom
simon-mundy:fix/resultset-object-rows

Conversation

@simon-mundy

@simon-mundy simon-mundy commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Clears the Mago findings in src/ResultSet by fixing the typing problems behind them, most of which came from AbstractResultSet::current() doing both buffering and row normalisation.

Changes

  • Each concrete set defines current(). currentRow() handles buffering and a new abstract mapRow() shapes the row.
  • Rows a set can't read throw PhpDb\ResultSet\Exception\UnexpectedValueException instead of becoming null.
  • New ObjectResultSet for FETCH_OBJ and FETCH_LAZY rows.
  • The $buffer sentinel is replaced by a private RowBufferState enum, with a fast path per state.
  • HydratingResultSet keeps returning the same objects on every buffered pass, opted into through HOLDS_MAPPED_ROWS.

Breaking changes

  • Subclasses must implement current() and mapRow(). parent::current(), rowToArray() and $buffer are gone.
  • FETCH_OBJ, FETCH_LAZY and FETCH_BOUND rows throw on the row-data sets.
  • getReturnType() returns the enum, so === self::TYPE_ARRAY checks are always false.
  • $dataSource and getDataSource() are typed ?Iterator.

mago lint, mago analyze and the unit tests pass.

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.
@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 simon-mundy self-assigned this Sep 30, 2026
@simon-mundy simon-mundy added bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request qa Improvements in quality assurance of the project next minor Target next minor release. labels Sep 30, 2026
@github-project-automation github-project-automation Bot moved this to Todo in @phpdb Sep 30, 2026
@simon-mundy simon-mundy added this to the 0.6.0 milestone Sep 30, 2026
@simon-mundy
simon-mundy requested a review from tyrsson September 30, 2026 00:42
@simon-mundy simon-mundy changed the title Return object rows rather than discarding them ResultSet Refactoring for Mago Sep 30, 2026
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.
- current() moves to each concrete set; mapRow() shapes the row
- buffering moves behind RowBuffer, with __clone to unshare it
- ObjectResultSet serves FETCH_OBJ and FETCH_LAZY rows
- rows are typed through a TRow template
- the data source narrows to ?Iterator
PHP rejects `self` inside an intersection type, so the capability interfaces
cannot take the rule's advice. Drops the three stale baseline entries.
The mysqli Result closes its statement once a fetch finds no row, so the
valid() that followed current() fetched again from a closed statement and
threw on an empty result. A null or false row now ends the read on its own,
which also restores the 0.6.x handling of a null row inside array data.
Iterating a ResultSet cost about 25 method calls per row against 7 in
0.6.x, nearly all of it RowBuffer bookkeeping that had nothing to do
once buffering was settled.

- RowBuffer folds back into AbstractResultSet as a RowBufferState and an
  array of held rows; rowBuffer() and __clone() go
- $readsDirectly and $storesRows, derived from the state by
  setBufferState(), give valid(), current() and next() a direct branch
  for each common state
- mapRow() reads plain arrays first and clones the row prototype
  property rather than calling its getter, as laminas-db did
A buffer held the rows mapRow() had shaped, so every pass handed out
the same objects: a change made to a row in one pass showed up in the
next, a row prototype set after a pass was ignored, and a buffer of
ArrayObjects cost around 15 times the memory of the raw arrays plus
cycle-collector scans.

The buffer now holds the rows as the data source returned them and maps
them on each read. ResultSet and RowPrototypeResultSet behave as they
did in 0.6.x; HydratingResultSet now hydrates a fresh object on every
pass instead of returning the same instance, which is documented.
mapRow() read the private row prototype and hydrator properties directly,
so a subclass overriding getRowPrototype() or getHydrator() no longer
decided how its rows were built. Each set now asks its getters once per
data source and keeps the answer, which honours the overrides without a
method call on every row.

A buffered HydratingResultSet hydrated every row again on each pass,
handing out new objects, so changes made to an entity in one pass were
lost in the next. Result sets can now opt into holding the rows mapRow()
built; HydratingResultSet does, and returns the same entities on every
pass as laminas-db and earlier PhpDb releases did. ResultSet and
RowPrototypeResultSet still build fresh rows from raw ones.
…he new hooks

currentRow() only reaches its last branch on the first read of an
unbuffered set: Storing with a data source always takes the buffered
branch, and every other settled state reads directly. The buffering code
left in that branch could not run, which left a line uncovered.

The tests now name the methods they exercise, so the new hooks count
towards coverage: holdRow(), holdsMappedRows() and
resetResolvedConfiguration(), plus RowPrototypeResultSet's constructor,
getter and setter.
- Resolve each row prototype in its own statement before cloning it.
  The formatter strips the parentheses from clone ($x ??= ...), and
  the analyzer then misreads the expression.
- HOLDS_MAPPED_ROWS replaces the holdsMappedRows() hook as a protected
  bool constant read through static::. Its @var bool stops the analyzer
  taking the base class's false as the only possible value.
- HydratingResultSet no longer overrides resetResolvedConfiguration(),
  which kept it within the method limit. It asks its getters on the
  first row and again after setHydrator() or setRowPrototype().
- Annotate the held row currentRow() returns as TRow.
- The lint baseline allows a third Halstead finding in
  AbstractResultSet, for initialize().
- The fetch mode guidance says FETCH_NUM, FETCH_BOTH and FETCH_KEY_PAIR
  fail in HydratingResultSet, whose hydrators reject integer keys.
- The AbstractResultSet API block shows the current signatures, mapRow()
  and the HOLDS_MAPPED_ROWS and resetResolvedConfiguration() extension
  points, in place of current() and toArray().
- The ResultSetReturnType listing includes Prototype, and the ResultSet
  constructor shows its real defaults.
- The toArray() sample catches ValueError rather than RuntimeException,
  and notes that catch (\Exception) misses it.
- What a Buffer Holds says when each set asks its getters again.
- Storing gets separate branches for raw and mapped rows, so a buffered
  read no longer calls holdRow() or reads HOLDS_MAPPED_ROWS per row.
- valid() has its own Storing branch, and its last path drops a held-row
  lookup that could never succeed.
- next() counts how far it has advanced the data source rather than
  comparing its key() with the position, so a source keyed other than
  0, 1, 2... is advanced instead of repeating its first row.
- ResultSet and RowPrototypeResultSet resolve the row prototype when a
  data source or prototype is set, not on each row.
ValueError extended PHP's \ValueError, an Error, so catch (\Exception)
missed a refused row. UnexpectedValueException extends the SPL class of
the same name, which catch (\RuntimeException) and catch (\Exception)
both reach, and joins ExceptionHierarchyTest.

Each forRowThatIs...() constructor now has its own template constant,
as the convention requires; the messages are unchanged.
PhpDb\Exception\ValueError had no other user and is removed.
@simon-mundy
simon-mundy merged commit e022af5 into php-db:0.6.x Oct 3, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request next minor Target next minor release. qa Improvements in quality assurance of the project

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants