diff --git a/analyzer-baseline.toml b/analyzer-baseline.toml index e17bd353..6a0f82f4 100644 --- a/analyzer-baseline.toml +++ b/analyzer-baseline.toml @@ -540,6 +540,18 @@ code = "incompatible-return-type" message = 'Return type `mixed` of `PhpDb\Adapter\Driver\Pdo\Result::next()` is incompatible with parent return type `void` of `Iterator::next()`' count = 1 +[[issues]] +file = "src/Adapter/Driver/Pdo/Result.php" +code = "less-specific-nested-argument-type" +message = 'Argument type mismatch for argument #1 of `PhpDb\ResultSet\AbstractResultSet::initialize`: expected `iterable`, but provided type `$this(PhpDb\Adapter\Driver\Pdo\Result)` is less specific.' +count = 1 + +[[issues]] +file = "src/Adapter/Driver/Pdo/Result.php" +code = "less-specific-nested-argument-type" +message = 'Argument type mismatch for argument #1 of `PhpDb\ResultSet\ResultSetInterface::initialize`: expected `iterable`, but provided type `$this(PhpDb\Adapter\Driver\Pdo\Result)` is less specific.' +count = 1 + [[issues]] file = "src/Adapter/Driver/Pdo/Result.php" code = "missing-constant-type" @@ -1305,7 +1317,7 @@ count = 1 [[issues]] file = "src/Container/AbstractAdapterInterfaceFactory.php" code = "possibly-null-argument" -message = 'Argument #3 of method `PhpDb\Adapter\Adapter::__construct` is possibly `null`, but parameter type `PhpDb\ResultSet\ResultSetInterface` does not accept it.' +message = 'Argument #3 of method `PhpDb\Adapter\Adapter::__construct` is possibly `null`, but parameter type `PhpDb\ResultSet\ResultSetInterface` does not accept it.' count = 1 [[issues]] @@ -1407,7 +1419,7 @@ count = 1 [[issues]] file = "src/Container/AdapterInterfaceFactory.php" code = "possibly-null-argument" -message = 'Argument #3 of method `PhpDb\Adapter\Adapter::__construct` is possibly `null`, but parameter type `PhpDb\ResultSet\ResultSetInterface` does not accept it.' +message = 'Argument #3 of method `PhpDb\Adapter\Adapter::__construct` is possibly `null`, but parameter type `PhpDb\ResultSet\ResultSetInterface` does not accept it.' count = 1 [[issues]] @@ -1500,24 +1512,6 @@ code = "missing-constant-type" message = "Class constant `DEFAULT_SCHEMA` is missing a type hint." count = 1 -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "imprecise-type" -message = "Type `array` in property `$buffer` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "imprecise-type" -message = "Type `array` in return type of `current` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "imprecise-type" -message = "Type `iterable` in parameter `$dataSource` is imprecise, equivalent to `iterable`." -count = 1 - [[issues]] file = "src/ResultSet/AbstractResultSet.php" code = "incompatible-return-type" @@ -1526,8 +1520,8 @@ count = 1 [[issues]] file = "src/ResultSet/AbstractResultSet.php" -code = "invalid-property-assignment-value" -message = 'Invalid type for property `$dataSource`: expected `Iterator|IteratorAggregate|PhpDb\Adapter\Driver\ResultInterface|null`, but got `iterable`.' +code = "invalid-return-statement" +message = 'Invalid return type for function `PhpDb\ResultSet\AbstractResultSet::resolveIterator`: expected `Iterator`, but found `DOMNodeList|DatePeriod|Ds\Collection|Generator|Iterator|PDOStatement`.' count = 1 [[issues]] @@ -1536,106 +1530,16 @@ code = "invalid-type-cast" message = "Casting `mixed` to `array`." count = 1 -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "missing-api-or-internal" -message = 'Abstract class `PhpDb\ResultSet\AbstractResultSet` is missing an `@api` or `@internal` annotation.' -count = 1 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "mixed-argument" -message = "Invalid argument type for argument #1 of `count`: expected `Countable|array`, but found `mixed`." -count = 1 - [[issues]] file = "src/ResultSet/AbstractResultSet.php" code = "mixed-assignment" message = "Assigning `mixed` type to a variable may lead to unexpected behavior." -count = 3 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "mixed-return-statement" -message = 'Could not infer a precise return type for function `PhpDb\ResultSet\AbstractResultSet::current`. Saw type `mixed`.' -count = 1 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "mixed-return-statement" -message = 'Could not infer a precise return type for function `PhpDb\ResultSet\AbstractResultSet::current`. Saw type `nonnull`.' -count = 1 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "mixed-return-statement" -message = 'Could not infer a precise return type for function `PhpDb\ResultSet\AbstractResultSet::valid`. Saw type `mixed`.' -count = 1 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "non-existent-method" -message = "Method `current` does not exist on type `IteratorAggregate`." -count = 3 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "non-existent-method" -message = "Method `key` does not exist on type `IteratorAggregate`." -count = 1 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "non-existent-method" -message = "Method `next` does not exist on type `IteratorAggregate`." -count = 1 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "non-existent-method" -message = "Method `rewind` does not exist on type `IteratorAggregate`." -count = 3 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "non-existent-method" -message = "Method `valid` does not exist on type `IteratorAggregate`." count = 2 [[issues]] file = "src/ResultSet/AbstractResultSet.php" -code = "possible-method-access-on-null" -message = "Attempting to call a method on `null`." -count = 6 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "possibly-invalid-argument" -message = 'Possible argument type mismatch for argument #1 of `count`: expected `Countable|array`, but possibly received `Iterator|IteratorAggregate|PhpDb\Adapter\Driver\ResultInterface`.' -count = 1 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "property-type-coercion" -message = 'A value of a less specific type `Traversable` is being assigned to property `$dataSource` (Iterator|IteratorAggregate|PhpDb\Adapter\Driver\ResultInterface|null).' -count = 1 - -[[issues]] -file = "src/ResultSet/ArrayObjectResultSetInterface.php" -code = "missing-api-or-internal" -message = 'Interface `PhpDb\ResultSet\ArrayObjectResultSetInterface` is missing an `@api` or `@internal` annotation.' -count = 1 - -[[issues]] -file = "src/ResultSet/ArrayResultSet.php" -code = "class-must-be-final" -message = 'Class `PhpDb\ResultSet\ArrayResultSet` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/ResultSet/ArrayResultSet.php" -code = "imprecise-type" -message = "Type `array` in return type of `toArray` is imprecise, equivalent to `array`." +code = "no-value" +message = 'Argument #1 passed to method `PhpDb\ResultSet\AbstractResultSet::initializeFromResult` has type `never`, meaning it cannot produce a value.' count = 1 [[issues]] @@ -1650,186 +1554,12 @@ code = "class-must-be-final" message = 'Class `PhpDb\ResultSet\Exception\RuntimeException` should be declared `final`.' count = 1 -[[issues]] -file = "src/ResultSet/HydratingResultSet.php" -code = "class-must-be-final" -message = 'Class `PhpDb\ResultSet\HydratingResultSet` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/ResultSet/HydratingResultSet.php" -code = "imprecise-type" -message = "Type `array` in return type of `toArray` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/ResultSet/HydratingResultSet.php" -code = "mixed-assignment" -message = "Assigning `mixed` type to a variable may lead to unexpected behavior." -count = 1 - -[[issues]] -file = "src/ResultSet/HydratingResultSet.php" -code = "mixed-return-statement" -message = 'Could not infer a precise return type for function `PhpDb\ResultSet\HydratingResultSet::current`. Saw type `nonnull`.' -count = 1 - -[[issues]] -file = "src/ResultSet/HydratingResultSet.php" -code = "non-existent-method" -message = "Method `current` does not exist on type `IteratorAggregate`." -count = 1 - -[[issues]] -file = "src/ResultSet/HydratingResultSet.php" -code = "overly-wide-return-type" -message = "Declared return type `null|object` for `getobjectprototype` has unused branches: `null`." -count = 1 - -[[issues]] -file = "src/ResultSet/HydratingResultSet.php" -code = "possible-method-access-on-null" -message = "Attempting to call a method on `null`." -count = 1 - -[[issues]] -file = "src/ResultSet/HydratingResultSet.php" -code = "possibly-null-argument" -message = 'Argument #1 of method `Laminas\Hydrator\ExtractionInterface::extract` is possibly `null`, but parameter type `object` does not accept it.' -count = 1 - -[[issues]] -file = "src/ResultSet/HydratingResultSetInterface.php" -code = "missing-api-or-internal" -message = 'Interface `PhpDb\ResultSet\HydratingResultSetInterface` is missing an `@api` or `@internal` annotation.' -count = 1 - -[[issues]] -file = "src/ResultSet/ResultSet.php" -code = "class-must-be-final" -message = 'Class `PhpDb\ResultSet\ResultSet` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/ResultSet/ResultSet.php" -code = "imprecise-type" -message = "Type `array` in return type of `current` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/ResultSet/ResultSet.php" -code = "imprecise-type" -message = "Type `array` in return type of `toArray` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/ResultSet/ResultSet.php" -code = "invalid-return-statement" -message = 'Invalid return type for function `PhpDb\ResultSet\ResultSet::getReturnType`: expected `enum(PhpDb\ResultSet\ResultSetReturnType)`, but found `enum(PhpDb\ResultSet\ResultSetReturnType)|string`.' -count = 1 - -[[issues]] -file = "src/ResultSet/ResultSet.php" -code = "less-specific-return-statement" -message = 'Returned type `array|null|object` is less specific than the declared return type `ArrayObject|array|null` for function `PhpDb\ResultSet\ResultSet::current`.' -count = 1 - -[[issues]] -file = "src/ResultSet/ResultSet.php" -code = "missing-constant-type" -message = "Class constant `TYPE_ARRAYOBJECT` is missing a type hint." -count = 1 - -[[issues]] -file = "src/ResultSet/ResultSet.php" -code = "missing-constant-type" -message = "Class constant `TYPE_ARRAY` is missing a type hint." -count = 1 - -[[issues]] -file = "src/ResultSet/ResultSetInterface.php" -code = "imprecise-type" -message = "Type `array` in return type of `toArray` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/ResultSet/ResultSetInterface.php" -code = "imprecise-type" -message = "Type `iterable` in parameter `$dataSource` is imprecise, equivalent to `iterable`." -count = 1 - -[[issues]] -file = "src/ResultSet/ResultSetInterface.php" -code = "missing-api-or-internal" -message = 'Interface `PhpDb\ResultSet\ResultSetInterface` is missing an `@api` or `@internal` annotation.' -count = 1 - -[[issues]] -file = "src/ResultSet/ResultSetInterface.php" -code = "missing-template-parameter" -message = "Too few template arguments for `Iterator`: expected at least 2, but found 0." -count = 1 - -[[issues]] -file = "src/ResultSet/RowPrototypeInterface.php" -code = "imprecise-type" -message = "Type `array` in parameter `$data` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/ResultSet/RowPrototypeInterface.php" -code = "imprecise-type" -message = "Type `array` in return type of `toArray` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/ResultSet/RowPrototypeInterface.php" -code = "missing-api-or-internal" -message = 'Interface `PhpDb\ResultSet\RowPrototypeInterface` is missing an `@api` or `@internal` annotation.' -count = 1 - -[[issues]] -file = "src/ResultSet/RowPrototypeResultSet.php" -code = "class-must-be-final" -message = 'Class `PhpDb\ResultSet\RowPrototypeResultSet` should be declared `final`.' -count = 1 - -[[issues]] -file = "src/ResultSet/RowPrototypeResultSet.php" -code = "imprecise-type" -message = "Type `array` in return type of `current` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/ResultSet/RowPrototypeResultSet.php" -code = "imprecise-type" -message = "Type `array` in return type of `toArray` is imprecise, equivalent to `array`." -count = 1 - -[[issues]] -file = "src/ResultSet/RowPrototypeResultSet.php" -code = "less-specific-return-statement" -message = 'Returned type `null|object` is less specific than the declared return type `PhpDb\ResultSet\RowPrototypeInterface|array|null` for function `PhpDb\ResultSet\RowPrototypeResultSet::current`.' -count = 1 - -[[issues]] -file = "src/ResultSet/RowPrototypeResultSetInterface.php" -code = "missing-api-or-internal" -message = 'Interface `PhpDb\ResultSet\RowPrototypeResultSetInterface` is missing an `@api` or `@internal` annotation.' -count = 1 - [[issues]] file = "src/RowGateway/AbstractRowGateway.php" code = "imprecise-type" message = "Type `array` in parameter `$array` is imprecise, equivalent to `array`." count = 1 -[[issues]] -file = "src/RowGateway/AbstractRowGateway.php" -code = "imprecise-type" -message = "Type `array` in parameter `$rowData` is imprecise, equivalent to `array`." -count = 1 - [[issues]] file = "src/RowGateway/AbstractRowGateway.php" code = "imprecise-type" @@ -1848,12 +1578,6 @@ code = "imprecise-type" message = "Type `array` in property `$primaryKeyData` is imprecise, equivalent to `array`." count = 1 -[[issues]] -file = "src/RowGateway/AbstractRowGateway.php" -code = "imprecise-type" -message = "Type `array` in return type of `toArray` is imprecise, equivalent to `array`." -count = 1 - [[issues]] file = "src/RowGateway/AbstractRowGateway.php" code = "incompatible-parameter-name" @@ -5223,7 +4947,7 @@ count = 1 [[issues]] file = "src/TableGateway/AbstractTableGateway.php" code = "invalid-return-statement" -message = 'Invalid return type for function `PhpDb\TableGateway\AbstractTableGateway::executeSelect`: expected `PhpDb\ResultSet\ResultSetInterface`, but found `PhpDb\ResultSet\ResultSetInterface|null`.' +message = 'Invalid return type for function `PhpDb\TableGateway\AbstractTableGateway::executeSelect`: expected `PhpDb\ResultSet\ResultSetInterface`, but found `PhpDb\ResultSet\ResultSetInterface|null`.' count = 1 [[issues]] @@ -5235,7 +4959,7 @@ count = 1 [[issues]] file = "src/TableGateway/AbstractTableGateway.php" code = "invalid-return-statement" -message = 'Invalid return type for function `PhpDb\TableGateway\AbstractTableGateway::getResultSetPrototype`: expected `PhpDb\ResultSet\ResultSetInterface`, but found `PhpDb\ResultSet\ResultSetInterface|null`.' +message = 'Invalid return type for function `PhpDb\TableGateway\AbstractTableGateway::getResultSetPrototype`: expected `PhpDb\ResultSet\ResultSetInterface`, but found `PhpDb\ResultSet\ResultSetInterface|null`.' count = 1 [[issues]] @@ -5259,7 +4983,7 @@ count = 1 [[issues]] file = "src/TableGateway/AbstractTableGateway.php" code = "mixed-argument" -message = 'Invalid argument type for argument #1 of `PhpDb\ResultSet\ResultSetInterface::initialize`: expected `iterable`, but found `mixed`.' +message = 'Invalid argument type for argument #1 of `PhpDb\ResultSet\ResultSetInterface::initialize`: expected `iterable`, but found `mixed`.' count = 1 [[issues]] @@ -5355,7 +5079,7 @@ count = 1 [[issues]] file = "src/TableGateway/AbstractTableGateway.php" code = "nullable-return-statement" -message = 'Function `PhpDb\TableGateway\AbstractTableGateway::executeSelect` is declared to return `PhpDb\ResultSet\ResultSetInterface` but possibly returns a nullable value (inferred as `PhpDb\ResultSet\ResultSetInterface|null`).' +message = 'Function `PhpDb\TableGateway\AbstractTableGateway::executeSelect` is declared to return `PhpDb\ResultSet\ResultSetInterface` but possibly returns a nullable value (inferred as `PhpDb\ResultSet\ResultSetInterface|null`).' count = 1 [[issues]] @@ -5367,7 +5091,7 @@ count = 1 [[issues]] file = "src/TableGateway/AbstractTableGateway.php" code = "nullable-return-statement" -message = 'Function `PhpDb\TableGateway\AbstractTableGateway::getResultSetPrototype` is declared to return `PhpDb\ResultSet\ResultSetInterface` but possibly returns a nullable value (inferred as `PhpDb\ResultSet\ResultSetInterface|null`).' +message = 'Function `PhpDb\TableGateway\AbstractTableGateway::getResultSetPrototype` is declared to return `PhpDb\ResultSet\ResultSetInterface` but possibly returns a nullable value (inferred as `PhpDb\ResultSet\ResultSetInterface|null`).' count = 1 [[issues]] @@ -5397,7 +5121,7 @@ count = 25 [[issues]] file = "src/TableGateway/AbstractTableGateway.php" code = "possibly-invalid-clone" -message = 'Expression of type `PhpDb\ResultSet\ResultSetInterface|null` might not be a cloneable object.' +message = 'Expression of type `PhpDb\ResultSet\ResultSetInterface|null` might not be a cloneable object.' count = 1 [[issues]] diff --git a/docs/book/result-set/advanced.md b/docs/book/result-set/advanced.md index d0398275..a9af3d03 100644 --- a/docs/book/result-set/advanced.md +++ b/docs/book/result-set/advanced.md @@ -15,8 +15,8 @@ use ArrayObject; class ResultSet extends AbstractResultSet { public function __construct( - ResultSetReturnType $returnType = ResultSetReturnType::ArrayObject, - ?ArrayObject $rowPrototype = null + ResultSetReturnType|string $returnType = ResultSetReturnType::ArrayObject, + ArrayObject $rowPrototype = new ArrayObject([], ArrayObject::ARRAY_AS_PROPS) ); public function setRowPrototype( @@ -39,6 +39,7 @@ enum ResultSetReturnType: string { case ArrayObject = 'arrayobject'; case Array = 'array'; + case Prototype = 'prototype'; } ``` @@ -56,10 +57,12 @@ $resultSet = new ResultSet(ResultSetReturnType::Array); - `ResultSetReturnType::ArrayObject` (default) - Returns rows as ArrayObject instances +- `ResultSetReturnType::Prototype` - The same as `ArrayObject`: rows fill a clone + of the row prototype. It is the newer name, matching `setRowPrototype()` - `ResultSetReturnType::Array` - Returns rows as plain PHP arrays **`$rowPrototype`** - Custom ArrayObject prototype for row objects -(only used with ArrayObject mode) +(only used with ArrayObject and Prototype modes) #### Return Type Modes @@ -200,6 +203,38 @@ RuntimeException: Buffering must be enabled before iteration is started ``` +### What a Buffer Holds + +What a buffered pass hands back depends on the kind of row. + +- `ResultSet` and `RowPrototypeResultSet` keep the rows as the data source returned + them and build each row afresh on every pass. A change made to an `ArrayObject` or + row prototype in one pass does not carry into the next. +- `HydratingResultSet` keeps the objects it hydrated on the first pass and hands the + same objects out on every later pass, as laminas-db and earlier PhpDb releases did. + A hydrated row is an entity with an identity of its own, so a change made to it in + one pass is still there in the next, and nothing is hydrated twice. + +```php title="Buffered Entities Keep Their Identity" +$resultSet = new HydratingResultSet(new ReflectionHydrator(), new UserEntity()); +$resultSet->initialize($result); +$resultSet->buffer(); + +foreach ($resultSet as $user) { + $user->setName(strtoupper($user->getName())); +} + +foreach ($resultSet as $user) { + echo $user->getName(); // upper-cased: the same objects as the first pass +} +``` + +Rows are built from what `getRowPrototype()` and `getHydrator()` return, so a subclass +that overrides either getter decides how its rows are built. `ResultSet` and +`RowPrototypeResultSet` ask once for each data source. `HydratingResultSet` asks on its +first row and again after `setRowPrototype()` or `setHydrator()`, so a getter whose +answer changes between data sources needs one of those setters called to take effect. + ### isBuffered() Method Checks if the result set is currently buffered: diff --git a/docs/book/result-set/examples.md b/docs/book/result-set/examples.md index cf4f1484..0b1c2158 100644 --- a/docs/book/result-set/examples.md +++ b/docs/book/result-set/examples.md @@ -205,16 +205,25 @@ try { } ``` -**toArray() on non-castable rows:** +### UnexpectedValueException + +**A row the result set cannot read**, such as a `PDO::FETCH_OBJ` row in a +`ResultSet`, is refused when it is read, whether by iterating, `current()` or +`toArray()`: ```php +use PhpDb\ResultSet\Exception\UnexpectedValueException; + try { - $resultSet->toArray(); -} catch (RuntimeException $e) { - printf("Error: Could not convert row to array\n"); + $rows = $resultSet->toArray(); +} catch (UnexpectedValueException $e) { + printf("Error: %s\n", $e->getMessage()); } ``` +It extends SPL's `\UnexpectedValueException`, so `catch (\RuntimeException $e)` and +`catch (\Exception $e)` catch it too, as does `PhpDb\Exception\ExceptionInterface`. + ## Troubleshooting ### Property Access Not Working diff --git a/docs/book/result-set/intro.md b/docs/book/result-set/intro.md index 9b1fb1d2..39d014db 100644 --- a/docs/book/result-set/intro.md +++ b/docs/book/result-set/intro.md @@ -69,16 +69,13 @@ functionality: namespace PhpDb\ResultSet; use Iterator; -use IteratorAggregate; -use PhpDb\Adapter\Driver\ResultInterface; -abstract class AbstractResultSet implements Iterator, ResultSetInterface +abstract class AbstractResultSet implements ResultSetInterface { - public function initialize( - array|Iterator|IteratorAggregate|ResultInterface $dataSource - ): ResultSetInterface; - public function getDataSource(): - array|Iterator|IteratorAggregate|ResultInterface; + protected const bool HOLDS_MAPPED_ROWS = false; + + public function initialize(iterable $dataSource): ResultSetInterface; + public function getDataSource(): ?Iterator; public function getFieldCount(): int; public function buffer(): ResultSetInterface; @@ -86,16 +83,25 @@ abstract class AbstractResultSet implements Iterator, ResultSetInterface public function next(): void; public function key(): int; - public function current(): mixed; public function valid(): bool; public function rewind(): void; - public function count(): int; + public function count(): ?int; - public function toArray(): array; + abstract protected function mapRow(mixed $row): mixed; + protected function resetResolvedConfiguration(): void; } ``` +`ResultSetInterface` extends `Iterator` and `Countable` and adds `toArray()`. Each +concrete result set declares `current()` with its own row type and implements it by +returning `$this->currentRow()`, which serves the row from the buffer or the data source +and passes it through `mapRow()` to give it that shape. A custom result set implements +`current()`, `mapRow()` and `toArray()`. It can redeclare `HOLDS_MAPPED_ROWS` as `true` +to hand out the same row objects on every buffered pass, and override +`resetResolvedConfiguration()` to forget anything it cached when `initialize()` is given +a new data source. + ## HydratingResultSet `PhpDb\ResultSet\HydratingResultSet` is a more flexible `ResultSet` object @@ -145,6 +151,55 @@ For more information, see the documentation to get a better sense of the different strategies that can be employed in order to populate a target object. +## ObjectResultSet + +`PhpDb\ResultSet\ObjectResultSet` is for fetch modes that yield objects, such as +`PDO::FETCH_OBJ` and `PDO::FETCH_LAZY`. Rows leave it as the very instances the driver +produced: nothing is cloned, hydrated or reshaped. + +```php title="Using ObjectResultSet with PDO::FETCH_OBJ" +use PDO; +use PhpDb\ResultSet\ObjectResultSet; + +$result = $statement->execute(); +$result->setFetchMode(PDO::FETCH_OBJ); + +$resultSet = new ObjectResultSet(); +$resultSet->initialize($result); + +foreach ($resultSet as $user) { + printf("%s %s\n", $user->first_name, $user->last_name); +} +``` + +`toArray()` reads each row's values, either by traversing it or by reading its public +properties. A row that exposes neither — a `PDORow` from `PDO::FETCH_LAZY`, for +instance — throws rather than yielding an empty array, so iterate such a result set +instead of calling `toArray()` on it. + +## Choosing a Result Set + +A result set fills its rows from row data, meaning an `array` or an `ArrayObject`. It +will not transform a row the driver handed it into some other shape, so the fetch mode +and the result set have to agree; where they do not, the row is refused with a +`PhpDb\ResultSet\Exception\UnexpectedValueException` naming both types. + +| Result set | Rows arrive as | Rows leave as | +|---|---|---| +| `ResultSet` | row data, or an `ArrayObject` | a filled `ArrayObject` prototype, an array, or that same `ArrayObject` | +| `ArrayResultSet` | row data | an array | +| `ObjectResultSet` | any object | that same object | +| `HydratingResultSet` | row data | the hydrated row prototype | +| `RowPrototypeResultSet` | row data, or a `RowPrototypeInterface` | a populated `RowPrototypeInterface` | + +With PDO that means `FETCH_ASSOC` and `FETCH_NAMED` suit every row-data set. +`FETCH_NUM`, `FETCH_BOTH` and `FETCH_KEY_PAIR` give rows with integer keys, which +`ResultSet`, `ArrayResultSet` and `RowPrototypeResultSet` accept but a hydrator cannot +map to properties, so `HydratingResultSet` fails on them with a `TypeError` from +laminas-hydrator. `FETCH_OBJ` and `FETCH_LAZY` need `ObjectResultSet`. `FETCH_BOUND` +yields only a success flag and binds its columns by reference, so no result set accepts +its rows; read the bound variables instead. + ## Data Source Types The `initialize()` method accepts arrays, `Iterator`, `IteratorAggregate`, diff --git a/lint-baseline.toml b/lint-baseline.toml index a167d2d1..82306798 100644 --- a/lint-baseline.toml +++ b/lint-baseline.toml @@ -256,96 +256,18 @@ count = 1 file = "src/ResultSet/AbstractResultSet.php" code = "halstead" message = "Method has a high halstead difficulty" -count = 2 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "no-else-clause" -message = "Avoid `else` clauses." -count = 1 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "no-else-clause" -message = "Avoid `elseif` clauses." count = 3 [[issues]] file = "src/ResultSet/AbstractResultSet.php" -code = "no-isset" -message = "Use of the `isset` construct." -count = 2 - -[[issues]] -file = "src/ResultSet/AbstractResultSet.php" -code = "no-redundant-else" -message = "The `if` branch always terminates; the trailing branches can be extracted." +code = "kan-defect" +message = "Class has a high kan defect score." count = 1 [[issues]] file = "src/ResultSet/AbstractResultSet.php" -code = "yoda-conditions" -message = "Use Yoda condition style for safer comparisons" -count = 5 - -[[issues]] -file = "src/ResultSet/ArrayObjectResultSetInterface.php" -code = "prefer-self-return-type" -message = "Return type `ArrayObjectResultSetInterface` refers to the enclosing class; use `self` instead." -count = 1 - -[[issues]] -file = "src/ResultSet/HydratingResultSet.php" -code = "halstead" -message = "Method has a high halstead difficulty" -count = 1 - -[[issues]] -file = "src/ResultSet/HydratingResultSet.php" -code = "no-else-clause" -message = "Avoid `elseif` clauses." -count = 1 - -[[issues]] -file = "src/ResultSet/HydratingResultSet.php" -code = "no-isset" -message = "Use of the `isset` construct." -count = 1 - -[[issues]] -file = "src/ResultSet/HydratingResultSet.php" -code = "yoda-conditions" -message = "Use Yoda condition style for safer comparisons" -count = 1 - -[[issues]] -file = "src/ResultSet/HydratingResultSetInterface.php" -code = "prefer-self-return-type" -message = "Return type `HydratingResultSetInterface` refers to the enclosing class; use `self` instead." -count = 1 - -[[issues]] -file = "src/ResultSet/ResultSet.php" -code = "yoda-conditions" -message = "Use Yoda condition style for safer comparisons" -count = 1 - -[[issues]] -file = "src/ResultSet/ResultSetInterface.php" -code = "prefer-self-return-type" -message = "Return type `ResultSetInterface` refers to the enclosing class; use `self` instead." -count = 1 - -[[issues]] -file = "src/ResultSet/RowPrototypeInterface.php" -code = "prefer-self-return-type" -message = "Return type `RowPrototypeInterface` refers to the enclosing class; use `self` instead." -count = 1 - -[[issues]] -file = "src/ResultSet/RowPrototypeResultSetInterface.php" -code = "prefer-self-return-type" -message = "Return type `RowPrototypeResultSetInterface` refers to the enclosing class; use `self` instead." +code = "too-many-methods" +message = "Class has too many methods." count = 1 [[issues]] diff --git a/mago.toml b/mago.toml index 1ddf6a0f..d91e430c 100644 --- a/mago.toml +++ b/mago.toml @@ -18,6 +18,14 @@ baseline = "lint-baseline.toml" # not apply. "test/" repeats the phpdb-qa-tools default, which this key replaces. too-many-methods = { exclude = ["test/", "src/Sql/Exception/", "src/TableGateway/Exception/", "src/Adapter/Exception/"] } +# The capability interfaces return `ResultSetInterface&`, and PHP rejects +# `self` as part of an intersection type, so this rule's advice cannot be taken there. +prefer-self-return-type = { exclude = [ + "src/ResultSet/ArrayObjectResultSetInterface.php", + "src/ResultSet/HydratingResultSetInterface.php", + "src/ResultSet/RowPrototypeResultSetInterface.php", +] } + [analyzer] baseline = "analyzer-baseline.toml" class-initializers = [ diff --git a/src/ResultSet/AbstractResultSet.php b/src/ResultSet/AbstractResultSet.php index b4ddc698..23ce8e53 100644 --- a/src/ResultSet/AbstractResultSet.php +++ b/src/ResultSet/AbstractResultSet.php @@ -5,6 +5,7 @@ namespace PhpDb\ResultSet; use ArrayIterator; +use ArrayObject; use Countable; use Exception; use Iterator; @@ -13,44 +14,139 @@ use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\ResultSet\Exception\InvalidArgumentException; use PhpDb\ResultSet\Exception\RuntimeException; +use PhpDb\ResultSet\Exception\UnexpectedValueException; use ReturnTypeWillChange; +use Traversable; use function count; use function current; +use function get_debug_type; use function is_array; use function reset; +/** + * @api + * + * @template TRow + * + * @implements ResultSetInterface + */ abstract class AbstractResultSet implements ResultSetInterface { + /** How deeply initialize() will unwrap a chain of IteratorAggregate. */ + private const int MAX_ITERATOR_DEPTH = 8; + + /** + * Whether buffered passes hand out the rows built on the first pass. + * + * False by default: the buffer keeps raw rows and every pass builds its own. A + * result set whose rows carry an identity of their own redeclares this as true. + * + * @var bool + */ + protected const bool HOLDS_MAPPED_ROWS = false; + + private RowBufferState $bufferState = RowBufferState::Pending; + + /** + * Whether rows are read straight from the data source, with no buffer work per row. + * + * True for Passthrough and Disabled, which are only ever entered once a data source + * is in place, so valid(), current() and next() can go straight to it. Derived from + * $bufferState and written only by setBufferState(), so the two cannot disagree. + */ + private bool $readsDirectly = false; + /** - * if -1, datasource is already buffered - * if -2, implicitly disabling buffering in ResultSet - * if false, explicitly disabled - * if null, default state - nothing, but can buffer until iteration started - * if array, already buffering + * Whether rows are held as they are read, with a data source in place to read them from. + * + * Derived from $bufferState and the data source by setBufferState(), which + * initialize() calls again whenever it replaces the data source. + */ + private bool $storesRows = false; + + /** + * Whether rows are held as mapRow() built them, for a result set that redeclares + * HOLDS_MAPPED_ROWS. Derived alongside $storesRows by setBufferState(). */ - protected int|array|bool|null $buffer = null; + private bool $storesMappedRows = false; + + /** + * Raw rows held for later passes, keyed by position, and mapped afresh on each read + * so that no pass sees what a caller did to the rows of another. + * + * Only filled while Storing, and emptied by initialize() before the state can + * leave Storing, so a row found here never needs the state checked as well. + * + * @var array + */ + private array $bufferedRows = []; protected ?int $count = null; - protected Iterator|IteratorAggregate|ResultInterface|null $dataSource = null; + /** + * Always an Iterator once initialize() has run: an IteratorAggregate is resolved + * before it is stored, and a ResultInterface is an Iterator in its own right. + */ + protected ?Iterator $dataSource = null; protected ?int $fieldCount = null; protected int $position = 0; + /** + * How many rows the data source has been advanced past while Storing. + * + * next() compares this, rather than the data source's key(), with the position, so + * a source keyed other than 0, 1, 2... is still advanced one row at a time. + */ + private int $sourcePosition = 0; + + /** + * Resolve an IteratorAggregate chain down to the Iterator it wraps. + * + * @throws InvalidArgumentException + * @throws Exception If the data source raises one while handing over its iterator. + */ + private static function resolveIterator(Traversable $dataSource): Iterator + { + $depth = 0; + while ($dataSource instanceof IteratorAggregate) { + if (++$depth > self::MAX_ITERATOR_DEPTH) { + throw InvalidArgumentException::forUnresolvableIterator(self::MAX_ITERATOR_DEPTH); + } + + $dataSource = $dataSource->getIterator(); + } + + if ($dataSource instanceof Iterator) { + return $dataSource; + } + + // @codeCoverageIgnoreStart + throw InvalidArgumentException::forNonIteratorDataSource($dataSource::class); + + // @codeCoverageIgnoreEnd + } + /** * @throws RuntimeException */ public function buffer(): ResultSetInterface { - if ($this->buffer === -2) { + if (RowBufferState::Disabled === $this->bufferState) { throw RuntimeException::forUnbufferedIteration(); - } elseif ($this->buffer === null) { - $this->buffer = []; - if ($this->dataSource instanceof ResultInterface) { - $this->dataSource->rewind(); - } + } + + if (RowBufferState::Pending !== $this->bufferState) { + return $this; + } + + $this->setBufferState(RowBufferState::Storing); + $this->sourcePosition = 0; + + if ($this->dataSource instanceof ResultInterface) { + $this->dataSource->rewind(); } return $this; @@ -63,7 +159,7 @@ public function buffer(): ResultSetInterface #[ReturnTypeWillChange] public function count(): ?int { - if ($this->count !== null) { + if (null !== $this->count) { return $this->count; } @@ -74,41 +170,18 @@ public function count(): ?int return $this->count; } - /** - * Iterator: get current item - */ - #[Override] - public function current(): array|object|null - { - if (-1 === $this->buffer) { - // datasource was an array when the resultset was initialized - return $this->dataSource->current(); - } - - if ($this->buffer === null) { - $this->buffer = -2; // implicitly disable buffering from here on - } elseif (is_array($this->buffer) && isset($this->buffer[$this->position])) { - return $this->buffer[$this->position]; - } - - $data = $this->dataSource->current(); - if (is_array($this->buffer)) { - $this->buffer[$this->position] = $data; - } - - return is_array($data) ? $data : null; - } - /** * Get the data source used to create the result set */ - public function getDataSource(): ResultInterface|IteratorAggregate|Iterator|null + public function getDataSource(): ?Iterator { return $this->dataSource; } /** * Retrieve count of fields in individual rows of the result set + * + * @throws RuntimeException */ #[Override] public function getFieldCount(): int @@ -117,11 +190,12 @@ public function getFieldCount(): int return $this->fieldCount; } - $dataSource = $this->getDataSource(); - if (null === $dataSource) { + if (null === $this->dataSource) { return 0; } + $dataSource = $this->dataSource(); + $dataSource->rewind(); if (! $dataSource->valid()) { $this->fieldCount = 0; @@ -147,46 +221,36 @@ public function getFieldCount(): int #[Override] public function initialize(iterable $dataSource): ResultSetInterface { - // reset buffering - if (is_array($this->buffer)) { - $this->buffer = []; - } + $this->bufferedRows = []; + $this->sourcePosition = 0; + $this->resetResolvedConfiguration(); if ($dataSource instanceof ResultInterface) { - $this->fieldCount = $dataSource->getFieldCount(); - $this->dataSource = $dataSource; - if ($dataSource->isBuffered()) { - $this->buffer = -1; - } - - if (is_array($this->buffer)) { - $this->dataSource->rewind(); - } + $this->initializeFromResult($dataSource); return $this; } - if (is_array($dataSource)) { - // its safe to get numbers from an array - $first = current($dataSource); - reset($dataSource); - $this->fieldCount = $first === false ? 0 : count($first); - $this->dataSource = new ArrayIterator($dataSource); - $this->buffer = -1; // array's are a natural buffer - } elseif ($dataSource instanceof IteratorAggregate) { - /** @phpstan-ignore assign.propertyType */ - $this->dataSource = $dataSource->getIterator(); - } else { - /** @phpstan-ignore assign.propertyType */ - $this->dataSource = $dataSource; + if ($dataSource instanceof Traversable) { + $this->dataSource = self::resolveIterator($dataSource); + $this->setBufferState($this->bufferState); + + return $this; } + $first = current($dataSource); + $this->fieldCount = is_array($first) || $first instanceof Countable ? count($first) : 0; + reset($dataSource); + $this->dataSource = new ArrayIterator($dataSource); + // An array is its own buffer + $this->setBufferState(RowBufferState::Passthrough); + return $this; } public function isBuffered(): bool { - return $this->buffer === -1 || is_array($this->buffer); + return RowBufferState::Storing === $this->bufferState || RowBufferState::Passthrough === $this->bufferState; } /** @@ -200,16 +264,41 @@ public function key(): int /** * Iterator: move pointer to next item + * + * @throws RuntimeException */ #[Override] public function next(): void { - if ($this->buffer === null) { - $this->buffer = -2; // implicitly disable buffering from here on + if ($this->readsDirectly) { + /** @var Iterator $dataSource */ + $dataSource = $this->dataSource; + $dataSource->next(); + $this->position++; + return; } - if (! is_array($this->buffer) || $this->position === $this->dataSource->key()) { - $this->dataSource->next(); + if ($this->storesRows) { + /** @var Iterator $dataSource */ + $dataSource = $this->dataSource; + if ($this->position === $this->sourcePosition) { + $dataSource->next(); + $this->sourcePosition++; + } + + $this->position++; + return; + } + + $dataSource = $this->dataSource ?? throw RuntimeException::forUninitialisedDataSource(); + + // While Storing, a pass over held rows leaves the data source where it stopped + if (RowBufferState::Storing !== $this->bufferState || $this->position === $dataSource->key()) { + $dataSource->next(); + } + + if (RowBufferState::Pending === $this->bufferState) { + $this->setBufferState(RowBufferState::Disabled); } $this->position++; @@ -217,12 +306,14 @@ public function next(): void /** * Iterator: rewind + * + * @throws RuntimeException */ #[Override] public function rewind(): void { - if (! is_array($this->buffer)) { - $this->dataSource->rewind(); + if (RowBufferState::Storing !== $this->bufferState) { + $this->dataSource()->rewind(); } $this->position = 0; @@ -230,14 +321,194 @@ public function rewind(): void /** * Iterator: is pointer valid? + * + * @throws RuntimeException */ #[Override] public function valid(): bool { - if (is_array($this->buffer) && isset($this->buffer[$this->position])) { - return true; + if ($this->readsDirectly) { + /** @var Iterator $dataSource */ + $dataSource = $this->dataSource; + return $dataSource->valid(); } - return $this->dataSource->valid(); + if ($this->storesRows) { + /** @var Iterator $dataSource */ + $dataSource = $this->dataSource; + + return null !== ($this->bufferedRows[$this->position] ?? null) || $dataSource->valid(); + } + + // Pending, or Storing before initialize() supplies a data source: nothing is held + return $this->dataSource()->valid(); + } + + /** + * Shape one raw data source row into the type this result set yields. + * + * Implementations narrow the return type to whatever their current() declares. + * + * @return TRow + */ + abstract protected function mapRow(mixed $row): mixed; + + /** + * The row at the current position, served from the buffer when one is held there. + * + * Concrete result sets call this from current() and shape the row in mapRow(), so + * that buffering lives here and row typing lives with the set that declares it. + * + * Every row of every pass comes through here, so the buffered path stays inline + * rather than being split out to meet the complexity limit. + * + * A driver Result signals the end of its rows with null or false, and asking it + * valid() afterwards may fetch again from a statement it has already closed, so + * either value ends the read on its own. + * + * @return TRow|null + * + * @throws RuntimeException + * + * @mago-expect lint:halstead + */ + protected function currentRow(): mixed + { + if ($this->readsDirectly) { + /** @var Iterator $dataSource */ + $dataSource = $this->dataSource; + + /** @var mixed $row */ + $row = $dataSource->current(); + + return null === $row || false === $row ? null : $this->mapRow($row); + } + + if ($this->storesMappedRows) { + /** @var TRow|null $held */ + $held = $this->bufferedRows[$this->position] ?? null; + + if (null !== $held) { + return $held; + } + + /** @var Iterator $dataSource */ + $dataSource = $this->dataSource; + + /** @var mixed $row */ + $row = $dataSource->current(); + + if (null === $row || false === $row) { + return null; + } + + return $this->bufferedRows[$this->position] = $this->mapRow($row); + } + + if ($this->storesRows) { + /** @var Iterator $dataSource */ + $dataSource = $this->dataSource; + + /** @var mixed $row */ + $row = $this->bufferedRows[$this->position] ?? $dataSource->current(); + + if (null === $row || false === $row) { + return null; + } + + $this->bufferedRows[$this->position] = $row; + + return $this->mapRow($row); + } + + // Only the first read of an unbuffered set lands here: Storing with a data source + // takes the branch above, and every other settled state reads directly. + $dataSource = $this->dataSource ?? throw RuntimeException::forUninitialisedDataSource(); + + $this->setBufferState(RowBufferState::Disabled); + + /** @var mixed $row */ + $row = $dataSource->current(); + + return null === $row || false === $row ? null : $this->mapRow($row); + } + + /** + * The data source, once initialize() has supplied one. + * + * @throws RuntimeException + */ + protected function dataSource(): Iterator + { + if (null === $this->dataSource) { + throw RuntimeException::forUninitialisedDataSource(); + } + + return $this->dataSource; + } + + /** + * The row data a prototype can be filled from. + * + * Only the types a result set may safely read are accepted; anything else is the + * caller's own object, which this component refuses to reshape. + * + * @return array + * + * @throws UnexpectedValueException If the row is neither an array nor an ArrayObject. + */ + protected function getArrayData(mixed $row): array + { + if (is_array($row)) { + return $row; + } + + if ($row instanceof ArrayObject) { + return $row->getArrayCopy(); + } + + throw $this->unsupportedRowError($row); + } + + /** + * Refresh anything a subclass resolves from its getters, so that each data source + * reads them once. Called by initialize() before the data source is taken. + */ + protected function resetResolvedConfiguration(): void {} + + /** + * The error describing a row this result set cannot read. + * + * Overridden by a result set that accepts more than row data, so that the message + * names everything it would have taken. + */ + protected function unsupportedRowError(mixed $row): UnexpectedValueException + { + return UnexpectedValueException::forRowThatIsNotArrayData(get_debug_type($row), static::class); + } + + private function initializeFromResult(ResultInterface $result): void + { + $this->fieldCount = $result->getFieldCount(); + $this->dataSource = $result; + + if ($result->isBuffered()) { + $this->setBufferState(RowBufferState::Passthrough); + return; + } + + $this->setBufferState($this->bufferState); + + if (RowBufferState::Storing === $this->bufferState) { + $result->rewind(); + } + } + + private function setBufferState(RowBufferState $state): void + { + $this->bufferState = $state; + $this->readsDirectly = RowBufferState::Passthrough === $state || RowBufferState::Disabled === $state; + $this->storesRows = RowBufferState::Storing === $state && null !== $this->dataSource; + $this->storesMappedRows = $this->storesRows && static::HOLDS_MAPPED_ROWS; } } diff --git a/src/ResultSet/ArrayObjectResultSetInterface.php b/src/ResultSet/ArrayObjectResultSetInterface.php index f73b82ce..95f9f6e1 100644 --- a/src/ResultSet/ArrayObjectResultSetInterface.php +++ b/src/ResultSet/ArrayObjectResultSetInterface.php @@ -8,6 +8,8 @@ /** * Capability interface for a ResultSet whose rows clone an ArrayObject prototype. + * + * @api */ interface ArrayObjectResultSetInterface { diff --git a/src/ResultSet/ArrayResultSet.php b/src/ResultSet/ArrayResultSet.php index 2f93eb4e..f9065dc1 100644 --- a/src/ResultSet/ArrayResultSet.php +++ b/src/ResultSet/ArrayResultSet.php @@ -6,8 +6,27 @@ use Override; +/** + * @api + * + * @extends AbstractResultSet> + */ class ArrayResultSet extends AbstractResultSet { + /** + * Iterator: get current item + * + * @return array|null + * + * @throws Exception\RuntimeException + * @throws Exception\UnexpectedValueException If a row is not row data. + */ + #[Override] + public function current(): ?array + { + return $this->currentRow(); + } + /** {@inheritDoc} */ #[Override] public function toArray(): array @@ -19,4 +38,15 @@ public function toArray(): array return $return; } + + /** + * @return array + * + * @throws Exception\UnexpectedValueException If the row is not row data. + */ + #[Override] + protected function mapRow(mixed $row): array + { + return $this->getArrayData($row); + } } diff --git a/src/ResultSet/Exception/InvalidArgumentException.php b/src/ResultSet/Exception/InvalidArgumentException.php index eea805db..3757942e 100644 --- a/src/ResultSet/Exception/InvalidArgumentException.php +++ b/src/ResultSet/Exception/InvalidArgumentException.php @@ -6,4 +6,21 @@ use PhpDb\Exception; -class InvalidArgumentException extends Exception\InvalidArgumentException {} +use function sprintf; + +class InvalidArgumentException extends Exception\InvalidArgumentException +{ + final public const string UNRESOLVABLE_ITERATOR = 'The data source nests IteratorAggregate more than %d levels deep and cannot be resolved to an Iterator'; + + final public const string NON_ITERATOR_DATA_SOURCE = 'The data source resolved to "%s", which is not an Iterator and cannot be traversed by a result set'; + + public static function forNonIteratorDataSource(string $type): self + { + return new self(sprintf(self::NON_ITERATOR_DATA_SOURCE, $type)); + } + + public static function forUnresolvableIterator(int $maxDepth): self + { + return new self(sprintf(self::UNRESOLVABLE_ITERATOR, $maxDepth)); + } +} diff --git a/src/ResultSet/Exception/RuntimeException.php b/src/ResultSet/Exception/RuntimeException.php index 4b972d66..57b6553c 100644 --- a/src/ResultSet/Exception/RuntimeException.php +++ b/src/ResultSet/Exception/RuntimeException.php @@ -10,8 +10,15 @@ class RuntimeException extends Exception\RuntimeException { final public const string UNBUFFERED_ITERATION = 'Buffering must be enabled before iteration is started'; + final public const string UNINITIALISED_DATA_SOURCE = 'The result set has no data source; call initialize() first'; + public static function forUnbufferedIteration(): self { return new self(self::UNBUFFERED_ITERATION); } + + public static function forUninitialisedDataSource(): self + { + return new self(self::UNINITIALISED_DATA_SOURCE); + } } diff --git a/src/ResultSet/Exception/UnexpectedValueException.php b/src/ResultSet/Exception/UnexpectedValueException.php new file mode 100644 index 00000000..9ad37542 --- /dev/null +++ b/src/ResultSet/Exception/UnexpectedValueException.php @@ -0,0 +1,40 @@ + + */ class HydratingResultSet extends AbstractResultSet implements HydratingResultSetInterface { + /** + * Hydrated rows are entities with an identity of their own, so a buffered set hands + * the same objects out on every pass, as laminas-db and earlier PhpDb releases did. + */ + protected const bool HOLDS_MAPPED_ROWS = true; + + /** + * getHydrator() and getRowPrototype(), asked on the first row rather than on every + * row, and asked again after setHydrator() or setRowPrototype(). + */ + private ?HydratorInterface $resolvedHydrator = null; + + private ?object $resolvedRowPrototype = null; + public function __construct( private ?HydratorInterface $hydrator = null, private ?object $rowPrototype = null, @@ -20,23 +40,14 @@ public function __construct( /** * Iterator: get current item + * + * @throws RuntimeException + * @throws Exception\UnexpectedValueException If a row is not row data. */ #[Override] public function current(): ?object { - if ($this->buffer === null) { - $this->buffer = -2; // implicitly disable buffering from here on - } elseif (is_array($this->buffer) && isset($this->buffer[$this->position])) { - return $this->buffer[$this->position]; - } - $data = $this->dataSource->current(); - $current = is_array($data) ? $this->getHydrator()->hydrate($data, clone $this->getRowPrototype()) : null; - - if (is_array($this->buffer)) { - $this->buffer[$this->position] = $current; - } - - return $current; + return $this->currentRow(); } /** @@ -48,7 +59,7 @@ public function getHydrator(): HydratorInterface } /** @deprecated use getRowPrototype() */ - public function getObjectPrototype(): ?object + public function getObjectPrototype(): object { return $this->getRowPrototype(); } @@ -65,7 +76,8 @@ public function getRowPrototype(): object */ public function setHydrator(HydratorInterface $hydrator): ResultSetInterface { - $this->hydrator = $hydrator; + $this->hydrator = $hydrator; + $this->resolvedHydrator = null; return $this; } @@ -77,24 +89,48 @@ public function setObjectPrototype(object $objectPrototype): ResultSetInterface /** {@inheritDoc} */ #[Override] - public function setRowPrototype(object $rowPrototype): ResultSetInterface&HydratingResultSetInterface - { - $this->rowPrototype = $rowPrototype; + public function setRowPrototype( + object $rowPrototype, + ): ResultSetInterface&HydratingResultSetInterface { + $this->rowPrototype = $rowPrototype; + $this->resolvedRowPrototype = null; return $this; } /** * Cast result set to array of arrays * - * @throws Exception\RuntimeException If any row is not castable to an array. + * @throws Exception\UnexpectedValueException If any row is not row data. */ #[Override] public function toArray(): array { $return = []; + + /** @var object $row Every row this set yields is hydrated, or mapRow() throws. */ foreach ($this as $row) { $return[] = $this->getHydrator()->extract($row); } + return $return; } + + /** + * Hydrate one row onto a clone of the prototype. + * + * @throws Exception\UnexpectedValueException If the row is not row data. + * @throws \Laminas\Hydrator\Exception\RuntimeException If the hydrator cannot fill the prototype. + */ + #[Override] + protected function mapRow(mixed $row): object + { + $data = is_array($row) ? $row : $this->getArrayData($row); + + $this->resolvedRowPrototype ??= $this->getRowPrototype(); + $prototype = clone $this->resolvedRowPrototype; + + $this->resolvedHydrator ??= $this->getHydrator(); + + return $this->resolvedHydrator->hydrate($data, $prototype); + } } diff --git a/src/ResultSet/HydratingResultSetInterface.php b/src/ResultSet/HydratingResultSetInterface.php index 011a90b5..936cec16 100644 --- a/src/ResultSet/HydratingResultSetInterface.php +++ b/src/ResultSet/HydratingResultSetInterface.php @@ -6,6 +6,8 @@ /** * Capability interface for a ResultSet whose rows are hydrated onto an arbitrary object prototype. + * + * @api */ interface HydratingResultSetInterface { diff --git a/src/ResultSet/ObjectResultSet.php b/src/ResultSet/ObjectResultSet.php new file mode 100644 index 00000000..667afef9 --- /dev/null +++ b/src/ResultSet/ObjectResultSet.php @@ -0,0 +1,92 @@ + + */ +class ObjectResultSet extends AbstractResultSet +{ + /** + * Iterator: get current item + * + * @throws Exception\RuntimeException + * @throws Exception\UnexpectedValueException If a row is not an object. + */ + #[Override] + public function current(): ?object + { + return $this->currentRow(); + } + + /** + * Cast result set to array of arrays + * + * @throws Exception\UnexpectedValueException If a row exposes nothing to cast. + */ + #[Override] + public function toArray(): array + { + $return = []; + + /** @var object $row Every row this set yields is an object, or mapRow() throws. */ + foreach ($this as $row) { + $return[] = $this->rowToArray($row); + } + + return $return; + } + + /** + * @throws Exception\UnexpectedValueException If the row is not an object. + */ + #[Override] + protected function mapRow(mixed $row): object + { + if (is_object($row)) { + return $row; + } + + throw Exception\UnexpectedValueException::forRowThatIsNotAnObject(get_debug_type($row), static::class); + } + + /** + * Read an object row's values for toArray(), which owes its caller arrays. + * + * @return array + * + * @throws Exception\UnexpectedValueException If the row exposes nothing to read. + */ + private function rowToArray(object $row): array + { + if ($row instanceof Traversable) { + return iterator_to_array($row); + } + + $data = get_object_vars($row); + + if ([] === $data) { + throw Exception\UnexpectedValueException::forUnreadableRow(get_debug_type($row), static::class); + } + + return $data; + } +} diff --git a/src/ResultSet/ResultSet.php b/src/ResultSet/ResultSet.php index f49d0bef..78ee864c 100644 --- a/src/ResultSet/ResultSet.php +++ b/src/ResultSet/ResultSet.php @@ -10,40 +10,59 @@ use function is_array; use function is_string; +/** + * @api + * + * @extends AbstractResultSet|ArrayObject> + */ class ResultSet extends AbstractResultSet implements ArrayObjectResultSetInterface { /** @deprecated use ResultSetReturnType */ - public const TYPE_ARRAYOBJECT = 'arrayobject'; - public const TYPE_ARRAY = 'array'; + public const string TYPE_ARRAYOBJECT = 'arrayobject'; + public const string TYPE_ARRAY = 'array'; + + private readonly ResultSetReturnType $returnType; + + /** + * Whether the selected return type fills the row prototype, resolved once. + * + * The match is exhaustive so that a return type added without a decision here + * fails at construction rather than falling through current() unnoticed. + */ + private readonly bool $fillsRowPrototype; + + /** + * getRowPrototype(), asked once per data source rather than once per row, so that a + * subclass overriding the getter is honoured without a method call on every row. + */ + private ?ArrayObject $resolvedRowPrototype = null; public function __construct( - private ResultSetReturnType|string $returnType = ResultSetReturnType::ArrayObject, + ResultSetReturnType|string $returnType = ResultSetReturnType::ArrayObject, private ArrayObject $rowPrototype = new ArrayObject( [], ArrayObject::ARRAY_AS_PROPS, ), ) { - if (is_string($this->returnType)) { - $this->returnType = ResultSetReturnType::from($this->returnType); - } + $this->returnType = is_string($returnType) ? ResultSetReturnType::from($returnType) : $returnType; + $this->fillsRowPrototype = match ($this->returnType) { + ResultSetReturnType::ArrayObject, ResultSetReturnType::Prototype => true, + ResultSetReturnType::Array => false, + }; } /** * Iterator: get current item + * + * @return array|ArrayObject|null + * + * @throws Exception\RuntimeException + * @throws Exception\UnexpectedValueException If a row is not row data. */ #[Override] public function current(): array|ArrayObject|null { - $data = parent::current(); - - if ($this->returnType === ResultSetReturnType::ArrayObject && is_array($data)) { - $ao = clone $this->getRowPrototype(); - $ao->exchangeArray($data); - - return $ao; - } - - return $data; + return $this->currentRow(); } /** @@ -74,16 +93,19 @@ public function getRowPrototype(): ArrayObject * * @deprecated use setRowPrototype() */ - public function setArrayObjectPrototype(ArrayObject $arrayObjectPrototype): ResultSetInterface&ArrayObjectResultSetInterface - { + public function setArrayObjectPrototype( + ArrayObject $arrayObjectPrototype, + ): ResultSetInterface&ArrayObjectResultSetInterface { return $this->setRowPrototype($arrayObjectPrototype); } /** {@inheritDoc} */ #[Override] - public function setRowPrototype(ArrayObject $rowPrototype): ResultSetInterface&ArrayObjectResultSetInterface - { - $this->rowPrototype = $rowPrototype; + public function setRowPrototype( + ArrayObject $rowPrototype, + ): ResultSetInterface&ArrayObjectResultSetInterface { + $this->rowPrototype = $rowPrototype; + $this->resolvedRowPrototype = $this->getRowPrototype(); return $this; } @@ -99,4 +121,42 @@ public function toArray(): array return $return; } + + /** + * An ArrayObject row is the caller's own object and is passed through untouched; + * row data fills the prototype when the return type calls for it. + * + * @return array|ArrayObject + * + * @throws Exception\UnexpectedValueException If the row is neither an ArrayObject nor row data. + */ + #[Override] + protected function mapRow(mixed $row): array|ArrayObject + { + if (! is_array($row)) { + if ($row instanceof ArrayObject) { + return $row; + } + + $row = $this->getArrayData($row); + } + + if (! $this->fillsRowPrototype) { + return $row; + } + + /** @var ArrayObject $prototype Resolved by initialize() before any row is read. */ + $prototype = $this->resolvedRowPrototype; + + $ao = clone $prototype; + $ao->exchangeArray($row); + + return $ao; + } + + #[Override] + protected function resetResolvedConfiguration(): void + { + $this->resolvedRowPrototype = $this->getRowPrototype(); + } } diff --git a/src/ResultSet/ResultSetInterface.php b/src/ResultSet/ResultSetInterface.php index dc0a3525..f5facdd9 100644 --- a/src/ResultSet/ResultSetInterface.php +++ b/src/ResultSet/ResultSetInterface.php @@ -7,6 +7,13 @@ use Countable; use Iterator; +/** + * @api + * + * @template TRow + * + * @extends Iterator + */ interface ResultSetInterface extends Iterator, Countable { /** @@ -18,11 +25,15 @@ public function getFieldCount(): int; /** * Can be anything iterable|array + * + * @param iterable $dataSource */ - public function initialize(iterable $dataSource): ResultSetInterface; + public function initialize(iterable $dataSource): self; /** * Get all rows as an array + * + * @return list */ public function toArray(): array; } diff --git a/src/ResultSet/RowBufferState.php b/src/ResultSet/RowBufferState.php new file mode 100644 index 00000000..66e3926c --- /dev/null +++ b/src/ResultSet/RowBufferState.php @@ -0,0 +1,31 @@ + $data */ - public function populate(array $data): RowPrototypeInterface; + public function populate(array $data): self; /** * Current data as an array and match current RowGateway implementations. + * + * @return array */ public function toArray(): array; } diff --git a/src/ResultSet/RowPrototypeResultSet.php b/src/ResultSet/RowPrototypeResultSet.php index 898a66bb..a08401bb 100644 --- a/src/ResultSet/RowPrototypeResultSet.php +++ b/src/ResultSet/RowPrototypeResultSet.php @@ -6,27 +6,33 @@ use Override; +use function get_debug_type; use function is_array; +/** + * @api + * + * @extends AbstractResultSet + */ class RowPrototypeResultSet extends AbstractResultSet implements RowPrototypeResultSetInterface { + /** getRowPrototype(), asked once per data source rather than once per row. */ + private ?RowPrototypeInterface $resolvedRowPrototype = null; + public function __construct( private RowPrototypeInterface $rowPrototype, ) {} /** * Iterator: get current item + * + * @throws Exception\RuntimeException + * @throws Exception\UnexpectedValueException If a row is not row data. */ #[Override] - public function current(): array|RowPrototypeInterface|null + public function current(): ?RowPrototypeInterface { - $data = parent::current(); - - if (is_array($data)) { - return (clone $this->getRowPrototype())->populate($data); - } - - return $data; + return $this->currentRow(); } /** {@inheritDoc} */ @@ -38,9 +44,11 @@ public function getRowPrototype(): RowPrototypeInterface /** {@inheritDoc} */ #[Override] - public function setRowPrototype(RowPrototypeInterface $rowPrototype): ResultSetInterface&RowPrototypeResultSetInterface - { - $this->rowPrototype = $rowPrototype; + public function setRowPrototype( + RowPrototypeInterface $rowPrototype, + ): ResultSetInterface&RowPrototypeResultSetInterface { + $this->rowPrototype = $rowPrototype; + $this->resolvedRowPrototype = $this->getRowPrototype(); return $this; } @@ -56,4 +64,39 @@ public function toArray(): array return $return; } + + /** + * A row that already satisfies the prototype interface is the caller's own object + * and is passed through untouched; row data populates a clone of the prototype. + * + * @throws Exception\UnexpectedValueException If the row is neither a RowPrototypeInterface nor row data. + */ + #[Override] + protected function mapRow(mixed $row): RowPrototypeInterface + { + if ($row instanceof RowPrototypeInterface) { + return $row; + } + + /** @var RowPrototypeInterface $prototype Resolved by initialize() before any row is read. */ + $prototype = $this->resolvedRowPrototype; + + return (clone $prototype)->populate(is_array($row) ? $row : $this->getArrayData($row)); + } + + #[Override] + protected function resetResolvedConfiguration(): void + { + $this->resolvedRowPrototype = $this->getRowPrototype(); + } + + /** {@inheritDoc} */ + #[Override] + protected function unsupportedRowError(mixed $row): Exception\UnexpectedValueException + { + return Exception\UnexpectedValueException::forRowThatIsNotArrayDataOrPrototype( + get_debug_type($row), + static::class, + ); + } } diff --git a/src/ResultSet/RowPrototypeResultSetInterface.php b/src/ResultSet/RowPrototypeResultSetInterface.php index ce10aa20..2b1f316d 100644 --- a/src/ResultSet/RowPrototypeResultSetInterface.php +++ b/src/ResultSet/RowPrototypeResultSetInterface.php @@ -6,6 +6,8 @@ /** * Capability interface for a ResultSet whose rows clone a RowPrototypeInterface prototype. + * + * @api */ interface RowPrototypeResultSetInterface { diff --git a/test/unit/Exception/ExceptionHierarchyTest.php b/test/unit/Exception/ExceptionHierarchyTest.php index 16c28db4..50f62b5c 100644 --- a/test/unit/Exception/ExceptionHierarchyTest.php +++ b/test/unit/Exception/ExceptionHierarchyTest.php @@ -73,6 +73,7 @@ final class ExceptionHierarchyTest extends TestCase MetadataException\RuntimeException::class => RuntimeException::class, ResultSetException\InvalidArgumentException::class => InvalidArgumentException::class, ResultSetException\RuntimeException::class => RuntimeException::class, + ResultSetException\UnexpectedValueException::class => UnexpectedValueException::class, RowGatewayException\InvalidArgumentException::class => InvalidArgumentException::class, RowGatewayException\RuntimeException::class => RuntimeException::class, SqlException\InvalidArgumentException::class => InvalidArgumentException::class, diff --git a/test/unit/ResultSet/AbstractResultSetIntegrationTest.php b/test/unit/ResultSet/AbstractResultSetIntegrationTest.php index a7cd3d59..5a9b80b1 100644 --- a/test/unit/ResultSet/AbstractResultSetIntegrationTest.php +++ b/test/unit/ResultSet/AbstractResultSetIntegrationTest.php @@ -7,16 +7,16 @@ use Override; use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\ResultSet\AbstractResultSet; +use PhpDbTest\ResultSet\TestAsset\PassThroughResultSet; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\Exception; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -#[CoversMethod(AbstractResultSet::class, 'current')] +#[CoversMethod(AbstractResultSet::class, 'currentRow')] final class AbstractResultSetIntegrationTest extends TestCase { - protected MockObject|AbstractResultSet $resultSet; + protected AbstractResultSet $resultSet; /** * @throws \Exception @@ -26,6 +26,7 @@ public function currentCallsDataSourceCurrentAsManyTimesWithoutBuffer(): void { $result = $this->getMockBuilder(ResultInterface::class)->getMock(); $this->resultSet->initialize($result); + $result->method('valid')->willReturn(true); $result->expects($this->exactly(3))->method('current')->willReturn(['foo' => 'bar']); // Call current() multiple times and verify data source is called each time $value1 = $this->resultSet->current(); @@ -43,6 +44,7 @@ public function currentCallsDataSourceCurrentOnceWithBuffer(): void $result = $this->getMockBuilder(ResultInterface::class)->getMock(); $this->resultSet->buffer(); $this->resultSet->initialize($result); + $result->method('valid')->willReturn(true); $result->expects($this->once())->method('current')->willReturn(['foo' => 'bar']); // Call current() multiple times and verify data source is called only once due to buffering $value1 = $this->resultSet->current(); @@ -60,8 +62,6 @@ public function currentCallsDataSourceCurrentOnceWithBuffer(): void #[Override] protected function setUp(): void { - $this->resultSet = $this->getMockBuilder(AbstractResultSet::class) - ->onlyMethods(['toArray']) - ->getMock(); + $this->resultSet = new PassThroughResultSet(); } } diff --git a/test/unit/ResultSet/AbstractResultSetTest.php b/test/unit/ResultSet/AbstractResultSetTest.php index 83bd3b7c..cc5af842 100644 --- a/test/unit/ResultSet/AbstractResultSetTest.php +++ b/test/unit/ResultSet/AbstractResultSetTest.php @@ -8,6 +8,7 @@ use ArrayObject; use Exception; use IteratorAggregate; +use LimitIterator; use NoRewindIterator; use Override; use PDOStatement; @@ -15,29 +16,35 @@ use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\ResultSet\AbstractResultSet; use PhpDb\ResultSet\Exception\RuntimeException; +use PhpDbTest\ResultSet\TestAsset\PassThroughResultSet; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use TypeError; use function assert; +use function iterator_to_array; #[CoversMethod(AbstractResultSet::class, 'initialize')] +#[CoversMethod(AbstractResultSet::class, 'resolveIterator')] +#[CoversMethod(AbstractResultSet::class, 'dataSource')] #[CoversMethod(AbstractResultSet::class, 'buffer')] #[CoversMethod(AbstractResultSet::class, 'isBuffered')] #[CoversMethod(AbstractResultSet::class, 'getDataSource')] #[CoversMethod(AbstractResultSet::class, 'getFieldCount')] #[CoversMethod(AbstractResultSet::class, 'next')] #[CoversMethod(AbstractResultSet::class, 'key')] -#[CoversMethod(AbstractResultSet::class, 'current')] +#[CoversMethod(AbstractResultSet::class, 'currentRow')] +#[CoversMethod(AbstractResultSet::class, 'initializeFromResult')] +#[CoversMethod(AbstractResultSet::class, 'setBufferState')] #[CoversMethod(AbstractResultSet::class, 'valid')] #[CoversMethod(AbstractResultSet::class, 'rewind')] #[CoversMethod(AbstractResultSet::class, 'count')] +#[CoversMethod(AbstractResultSet::class, 'resetResolvedConfiguration')] final class AbstractResultSetTest extends TestCase { - protected MockObject|AbstractResultSet $resultSet; + protected AbstractResultSet $resultSet; /** * @throws Exception @@ -62,6 +69,75 @@ public function buffer(): void $resultSet->buffer(); } + #[Test] + public function bufferAfterABufferedPassHasBegunKeepsBuffering(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize(new ArrayIterator([['id' => 1], ['id' => 2]])); + $resultSet->buffer(); + $resultSet->current(); + $resultSet->next(); + + $resultSet->buffer(); + $resultSet->rewind(); + + static::assertSame(['id' => 1], $resultSet->current()); + } + + #[Test] + public function bufferAfterIteratingAnArrayDataSourceIsAllowed(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize([['id' => 1], ['id' => 2]]); + $resultSet->current(); + $resultSet->next(); + + $resultSet->buffer(); + + static::assertTrue($resultSet->isBuffered()); + } + + /** + * @throws Exception + */ + #[Test] + public function bufferBeforeInitializeHoldsTheRowsOfTheDataSourceGivenLater(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->buffer(); + $resultSet->initialize(new NoRewindIterator(new ArrayIterator([['id' => 1], ['id' => 2]]))); + iterator_to_array($resultSet); + + static::assertSame([['id' => 1], ['id' => 2]], iterator_to_array($resultSet)); + } + + #[Test] + public function bufferCalledTwiceKeepsTheRowsAlreadyHeld(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize(new ArrayIterator([['id' => 1]])); + $resultSet->buffer(); + $resultSet->current(); + + $resultSet->buffer(); + $resultSet->rewind(); + + static::assertSame(['id' => 1], $resultSet->current()); + } + + #[Test] + public function bufferedPassAdvancesADataSourceKeyedOtherThanByPosition(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->buffer(); + $resultSet->initialize(new ArrayIterator(['a' => ['id' => 1], 'b' => ['id' => 2], 'c' => ['id' => 3]])); + + // A data source that is never advanced repeats its first row; a fourth read shows it + $rows = iterator_to_array(new LimitIterator($resultSet, 0, 4), preserve_keys: false); + + static::assertSame([['id' => 1], ['id' => 2], ['id' => 3]], $rows); + } + /** * Test multiple iterations with buffer * @@ -98,6 +174,31 @@ public function bufferIterations(): void static::assertSame(3, $data['id']); } + /** + * Sets up the fixture, for example, opens a network connection. + * This method is called before a test is executed. + */ + /** + * A table gateway clones its result set prototype for every select, so two result + * sets cloned from one prototype must not hand each other's rows back. + * + * @throws Exception + */ + #[Test] + public function cloningAResultSetDoesNotShareTheRowsItHolds(): void + { + $prototype = new PassThroughResultSet(); + $prototype->initialize(new ArrayIterator([['id' => 1]])); + $prototype->buffer(); + $prototype->current(); + + $clone = clone $prototype; + $clone->initialize(new ArrayIterator([['id' => 2]])); + + static::assertSame(['id' => 2], $clone->current()); + static::assertSame(['id' => 1], $prototype->current()); + } + #[Test] public function countReturnsCachedResult(): void { @@ -153,6 +254,48 @@ public function current(): void static::assertEquals(['id' => 1, 'name' => 'one'], $resultSet->current()); } + /** + * The mysqli Result closes its statement once a fetch finds no row, so a valid() + * asked after that fetches again from a closed statement. + * + * @throws Exception + */ + #[Test] + public function currentDoesNotAskAnExhaustedDriverResultWhetherItIsValid(): void + { + $result = $this->createMock(ResultInterface::class); + $result->method('current')->willReturn(null); + $result->expects(self::never())->method('valid'); + + $resultSet = $this->createResultSetMock(); + $resultSet->initialize($result); + + static::assertNull($resultSet->current()); + } + + #[Test] + public function currentReportsAnUninitialisedDataSourceRatherThanFailingOnNull(): void + { + $resultSet = $this->createResultSetMock(); + + self::expectException(RuntimeException::class); + self::expectExceptionMessage(RuntimeException::UNINITIALISED_DATA_SOURCE); + + $resultSet->current(); + } + + #[Test] + public function currentReportsAnUninitialisedDataSourceWhileBuffering(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->buffer(); + + self::expectException(RuntimeException::class); + self::expectExceptionMessage(RuntimeException::UNINITIALISED_DATA_SOURCE); + + $resultSet->current(); + } + #[Test] public function currentReturnsBufferedDataOnSecondPass(): void { @@ -178,6 +321,27 @@ public function currentReturnsBufferedDataOnSecondPass(): void static::assertEquals($firstPass, $secondPass); } + #[Test] + public function currentReturnsNullForAFalseRowFromTheDataSource(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize(new ArrayIterator([false])); + + static::assertNull($resultSet->current()); + } + + #[Test] + public function currentReturnsNullPastTheLastRowWhileBuffering(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->initialize(new ArrayIterator([['id' => 1]])); + $resultSet->buffer(); + $resultSet->current(); + $resultSet->next(); + + static::assertNull($resultSet->current()); + } + /** * @throws Exception */ @@ -465,6 +629,29 @@ public function next(): void static::assertSame(1, $resultSet->key()); } + #[Test] + public function nextReportsAnUninitialisedDataSourceWhileBuffering(): void + { + $resultSet = $this->createResultSetMock(); + $resultSet->buffer(); + + self::expectException(RuntimeException::class); + self::expectExceptionMessage(RuntimeException::UNINITIALISED_DATA_SOURCE); + + $resultSet->next(); + } + + #[Test] + public function rewindReportsAnUninitialisedDataSourceRatherThanFailingOnNull(): void + { + $resultSet = $this->createResultSetMock(); + + self::expectException(RuntimeException::class); + self::expectExceptionMessage(RuntimeException::UNINITIALISED_DATA_SOURCE); + + $resultSet->rewind(); + } + /** * @throws Exception */ @@ -545,6 +732,17 @@ public function validAnswersFromTheBufferAfterTheDataSourceIsExhausted(): void static::assertTrue($resultSet->valid()); } + #[Test] + public function validReportsAnUninitialisedDataSourceRatherThanFailingOnNull(): void + { + $resultSet = $this->createResultSetMock(); + + self::expectException(RuntimeException::class); + self::expectExceptionMessage(RuntimeException::UNINITIALISED_DATA_SOURCE); + + $resultSet->valid(); + } + #[Test] public function validReturnsFalseAfterLastElement(): void { @@ -587,21 +785,15 @@ public function getIterator(): ArrayIterator static::assertTrue($resultSet->valid()); } - /** - * Sets up the fixture, for example, opens a network connection. - * This method is called before a test is executed. - */ #[Override] protected function setUp(): void { $this->resultSet = $this->createResultSetMock(); } - private function createResultSetMock(): MockObject|AbstractResultSet + private function createResultSetMock(): AbstractResultSet { - return $this->getMockBuilder(AbstractResultSet::class) - ->onlyMethods(['toArray']) - ->getMock(); + return new PassThroughResultSet(); } /** @@ -610,7 +802,7 @@ private function createResultSetMock(): MockObject|AbstractResultSet * * @throws Exception */ - private function drainIntoBuffer(): MockObject|AbstractResultSet + private function drainIntoBuffer(): AbstractResultSet { $resultSet = $this->createResultSetMock(); $resultSet->initialize(new ArrayIterator([ diff --git a/test/unit/ResultSet/ArrayResultSetTest.php b/test/unit/ResultSet/ArrayResultSetTest.php index 75b46a9c..1adc6901 100644 --- a/test/unit/ResultSet/ArrayResultSetTest.php +++ b/test/unit/ResultSet/ArrayResultSetTest.php @@ -4,16 +4,80 @@ namespace PhpDbTest\ResultSet; +use ArrayIterator; +use ArrayObject; +use PhpDb\ResultSet\AbstractResultSet; use PhpDb\ResultSet\ArrayResultSet; +use PhpDb\ResultSet\Exception\UnexpectedValueException; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use stdClass; +#[CoversMethod(AbstractResultSet::class, 'getArrayData')] +#[CoversMethod(AbstractResultSet::class, 'unsupportedRowError')] +#[CoversMethod(ArrayResultSet::class, 'current')] +#[CoversMethod(ArrayResultSet::class, 'mapRow')] #[CoversMethod(ArrayResultSet::class, 'toArray')] #[Group('unit')] final class ArrayResultSetTest extends TestCase { + #[Test] + public function currentReducesARowCarryingItsValuesAsElements(): void + { + $resultSet = new ArrayResultSet(); + $resultSet->initialize(new ArrayIterator([new ArrayObject(['id' => 1, 'name' => 'one'])])); + + static::assertSame(['id' => 1, 'name' => 'one'], $resultSet->current()); + } + + #[Test] + public function currentRejectsAnObjectRowRatherThanReducingIt(): void + { + $row = new stdClass(); + $row->id = 1; + $row->name = 'one'; + + $resultSet = new ArrayResultSet(); + $resultSet->initialize(new ArrayIterator([$row])); + + self::expectException(UnexpectedValueException::class); + self::expectExceptionMessage('A row of type "stdClass"'); + + $resultSet->current(); + } + + #[Test] + public function currentRejectsARowThatExposesNothing(): void + { + $resultSet = new ArrayResultSet(); + $resultSet->initialize(new ArrayIterator([new stdClass()])); + + self::expectException(UnexpectedValueException::class); + self::expectExceptionMessage('will not transform a row it did not create'); + + $resultSet->current(); + } + + #[Test] + public function currentReturnsAnArrayRowUntouched(): void + { + $resultSet = new ArrayResultSet(); + $resultSet->initialize([['id' => 1, 'name' => 'one']]); + + static::assertSame(['id' => 1, 'name' => 'one'], $resultSet->current()); + } + + #[Test] + public function currentReturnsNullOnceTheRowsAreExhausted(): void + { + $resultSet = new ArrayResultSet(); + $resultSet->initialize(new ArrayIterator([])); + + static::assertNull($resultSet->current()); + } + #[Test] public function toArrayReturnsRowsAsProvided(): void { diff --git a/test/unit/ResultSet/Exception/InvalidArgumentExceptionTest.php b/test/unit/ResultSet/Exception/InvalidArgumentExceptionTest.php new file mode 100644 index 00000000..c330e5b2 --- /dev/null +++ b/test/unit/ResultSet/Exception/InvalidArgumentExceptionTest.php @@ -0,0 +1,48 @@ +getMessage(), + ); + } + + #[Test] + public function forNonIteratorDataSourceReturnsTheComponentExceptionType(): void + { + self::assertInstanceOf( + ExceptionInterface::class, + InvalidArgumentException::forNonIteratorDataSource('DatePeriod'), + ); + } + + #[Test] + public function forUnresolvableIteratorNamesTheDepthItGaveUpAt(): void + { + self::assertStringContainsString('8', InvalidArgumentException::forUnresolvableIterator(8)->getMessage()); + } + + #[Test] + public function forUnresolvableIteratorReturnsTheComponentExceptionType(): void + { + self::assertInstanceOf(ExceptionInterface::class, InvalidArgumentException::forUnresolvableIterator(8)); + } +} diff --git a/test/unit/ResultSet/Exception/RuntimeExceptionTest.php b/test/unit/ResultSet/Exception/RuntimeExceptionTest.php index bab9cbdb..a2f1ab3b 100644 --- a/test/unit/ResultSet/Exception/RuntimeExceptionTest.php +++ b/test/unit/ResultSet/Exception/RuntimeExceptionTest.php @@ -13,6 +13,7 @@ #[Group('unit')] #[CoversMethod(RuntimeException::class, 'forUnbufferedIteration')] +#[CoversMethod(RuntimeException::class, 'forUninitialisedDataSource')] final class RuntimeExceptionTest extends TestCase { #[Test] @@ -29,4 +30,19 @@ public function forUnbufferedIterationReturnsTheComponentExceptionType(): void { self::assertInstanceOf(ExceptionInterface::class, RuntimeException::forUnbufferedIteration()); } + + #[Test] + public function forUninitialisedDataSourceRendersItsTemplate(): void + { + self::assertSame( + RuntimeException::UNINITIALISED_DATA_SOURCE, + RuntimeException::forUninitialisedDataSource()->getMessage(), + ); + } + + #[Test] + public function forUninitialisedDataSourceReturnsTheComponentExceptionType(): void + { + self::assertInstanceOf(ExceptionInterface::class, RuntimeException::forUninitialisedDataSource()); + } } diff --git a/test/unit/ResultSet/Exception/UnexpectedValueExceptionTest.php b/test/unit/ResultSet/Exception/UnexpectedValueExceptionTest.php new file mode 100644 index 00000000..59e040a0 --- /dev/null +++ b/test/unit/ResultSet/Exception/UnexpectedValueExceptionTest.php @@ -0,0 +1,106 @@ +getMessage(), + ); + } + + #[Test] + public function forRowThatIsNotArrayDataIsCatchableAsAnException(): void + { + self::assertInstanceOf( + Exception::class, + UnexpectedValueException::forRowThatIsNotArrayData('bool', ResultSet::class), + ); + } + + #[Test] + public function forRowThatIsNotArrayDataNamesTheAcceptedTypes(): void + { + self::assertStringContainsString( + 'which accepts an array or ArrayObject', + UnexpectedValueException::forRowThatIsNotArrayData('PDORow', ResultSet::class)->getMessage(), + ); + } + + #[Test] + public function forRowThatIsNotArrayDataNamesTheOffendingType(): void + { + self::assertStringContainsString( + 'A row of type "PDORow"', + UnexpectedValueException::forRowThatIsNotArrayData('PDORow', ResultSet::class)->getMessage(), + ); + } + + #[Test] + public function forRowThatIsNotArrayDataNamesTheResultSetThatRefusedIt(): void + { + self::assertStringContainsString( + ResultSet::class, + UnexpectedValueException::forRowThatIsNotArrayData('PDORow', ResultSet::class)->getMessage(), + ); + } + + #[Test] + public function forRowThatIsNotArrayDataOrPrototypeNamesTheAcceptedTypes(): void + { + self::assertStringContainsString( + 'which accepts an array, ArrayObject or RowPrototypeInterface', + UnexpectedValueException::forRowThatIsNotArrayDataOrPrototype('stdClass', RowPrototypeResultSet::class) + ->getMessage(), + ); + } + + #[Test] + public function forRowThatIsNotArrayDataReturnsTheComponentExceptionType(): void + { + self::assertInstanceOf( + ExceptionInterface::class, + UnexpectedValueException::forRowThatIsNotArrayData('bool', ResultSet::class), + ); + } + + #[Test] + public function forUnreadableRowNamesTheOffendingType(): void + { + self::assertStringContainsString( + 'A row of type "PDORow"', + UnexpectedValueException::forUnreadableRow('PDORow', ResultSet::class)->getMessage(), + ); + } + + #[Test] + public function forUnreadableRowReturnsTheComponentExceptionType(): void + { + self::assertInstanceOf( + ExceptionInterface::class, + UnexpectedValueException::forUnreadableRow('PDORow', ResultSet::class), + ); + } +} diff --git a/test/unit/ResultSet/HydratingResultSetIntegrationTest.php b/test/unit/ResultSet/HydratingResultSetIntegrationTest.php index 04952f3d..ad9022f0 100644 --- a/test/unit/ResultSet/HydratingResultSetIntegrationTest.php +++ b/test/unit/ResultSet/HydratingResultSetIntegrationTest.php @@ -18,7 +18,7 @@ class HydratingResultSetIntegrationTest extends TestCase * @throws Exception */ #[Test] - public function currentWillReturnBufferedRow(): void + public function aSecondBufferedPassHydratesTheSameData(): void { $hydratingRs = new HydratingResultSet(); $hydratingRs->initialize(new ArrayIterator([ @@ -27,10 +27,9 @@ public function currentWillReturnBufferedRow(): void ])); $hydratingRs->buffer(); - // Get current object and rewind to verify same buffered object is returned - $obj1 = $hydratingRs->current(); + $first = $hydratingRs->current(); $hydratingRs->rewind(); - $obj2 = $hydratingRs->current(); - static::assertSame($obj1, $obj2); + + static::assertEquals($first, $hydratingRs->current()); } } diff --git a/test/unit/ResultSet/HydratingResultSetTest.php b/test/unit/ResultSet/HydratingResultSetTest.php index d8e79255..22249ef1 100644 --- a/test/unit/ResultSet/HydratingResultSetTest.php +++ b/test/unit/ResultSet/HydratingResultSetTest.php @@ -9,8 +9,11 @@ use Exception; use Laminas\Hydrator\ArraySerializableHydrator; use Laminas\Hydrator\ClassMethodsHydrator; +use Laminas\Hydrator\ObjectPropertyHydrator; use Override; +use PhpDb\ResultSet\AbstractResultSet; use PhpDb\ResultSet\Exception\RuntimeException; +use PhpDb\ResultSet\Exception\UnexpectedValueException; use PhpDb\ResultSet\HydratingResultSet; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; @@ -18,15 +21,19 @@ use PHPUnit\Framework\TestCase; use stdClass; +#[CoversMethod(AbstractResultSet::class, 'getArrayData')] +#[CoversMethod(AbstractResultSet::class, 'unsupportedRowError')] #[CoversMethod(HydratingResultSet::class, 'setObjectPrototype')] #[CoversMethod(HydratingResultSet::class, 'getObjectPrototype')] #[CoversMethod(HydratingResultSet::class, 'setHydrator')] #[CoversMethod(HydratingResultSet::class, 'getHydrator')] #[CoversMethod(HydratingResultSet::class, 'current')] +#[CoversMethod(HydratingResultSet::class, 'mapRow')] #[CoversMethod(HydratingResultSet::class, 'toArray')] #[CoversMethod(HydratingResultSet::class, '__construct')] #[CoversMethod(HydratingResultSet::class, 'setRowPrototype')] #[CoversMethod(HydratingResultSet::class, 'getRowPrototype')] +#[CoversMethod(AbstractResultSet::class, 'currentRow')] #[Group('unit')] final class HydratingResultSetTest extends TestCase { @@ -34,6 +41,72 @@ final class HydratingResultSetTest extends TestCase private string $classMethodsHydratorClass; + #[Test] + public function aBufferedReadPastTheLastRowReturnsNull(): void + { + $hydratingRs = new HydratingResultSet(); + $hydratingRs->initialize(new ArrayIterator([['id' => 1]])); + $hydratingRs->buffer(); + $hydratingRs->current(); + $hydratingRs->next(); + + static::assertNull($hydratingRs->current()); + } + + #[Test] + public function aChangeToABufferedEntityCarriesIntoTheNextPass(): void + { + $hydratingRs = new HydratingResultSet(null, new ArrayObject([], ArrayObject::ARRAY_AS_PROPS)); + $hydratingRs->initialize(new ArrayIterator([ + ['id' => 1, 'name' => 'one'], + ['id' => 2, 'name' => 'two'], + ])); + $hydratingRs->buffer(); + + foreach ($hydratingRs as $row) { + $row['name'] = 'changed'; + } + + $names = []; + foreach ($hydratingRs as $row) { + $names[] = $row['name']; + } + + static::assertSame(['changed', 'changed'], $names); + } + + #[Test] + public function aNewHydratorOrPrototypeIsUsedForTheNextDataSource(): void + { + $hydratingRs = new HydratingResultSet(null, new ArrayObject()); + $hydratingRs->initialize(new ArrayIterator([['id' => 1]])); + static::assertInstanceOf(ArrayObject::class, $hydratingRs->current()); + + $hydratingRs->setRowPrototype(new stdClass()); + $hydratingRs->setHydrator(new ObjectPropertyHydrator()); + $hydratingRs->initialize(new ArrayIterator([['id' => 2]])); + + $row = $hydratingRs->current(); + static::assertInstanceOf(stdClass::class, $row); + static::assertSame(2, $row->id); + } + + #[Test] + public function aSecondBufferedPassReturnsTheSameObject(): void + { + $hydratingRs = new HydratingResultSet(); + $hydratingRs->initialize(new ArrayIterator([ + ['id' => 1, 'name' => 'one'], + ['id' => 2, 'name' => 'two'], + ])); + $hydratingRs->buffer(); + + $first = $hydratingRs->current(); + $hydratingRs->rewind(); + + static::assertSame($first, $hydratingRs->current()); + } + #[Test] public function constructorDefaultsToArraySerializableHydrator(): void { @@ -86,20 +159,31 @@ public function currentHasData(): void } #[Test] - public function currentWithBufferReturnsBufferedObject(): void + public function currentRejectsAnObjectRowRatherThanHydratingIt(): void { - $hydratingRs = new HydratingResultSet(); - $hydratingRs->initialize(new ArrayIterator([ - ['id' => 1, 'name' => 'one'], - ['id' => 2, 'name' => 'two'], - ])); - $hydratingRs->buffer(); + $row = new stdClass(); + $row->id = 1; + $row->name = 'one'; - $first = $hydratingRs->current(); - $hydratingRs->rewind(); - $buffered = $hydratingRs->current(); + $resultSet = new HydratingResultSet(new ArraySerializableHydrator(), new ArrayObject()); + $resultSet->initialize(new ArrayIterator([$row])); - static::assertSame($first, $buffered); + self::expectException(UnexpectedValueException::class); + self::expectExceptionMessage('A row of type "stdClass"'); + + $resultSet->current(); + } + + #[Test] + public function currentRejectsARowThatExposesNothing(): void + { + $resultSet = new HydratingResultSet(new ArraySerializableHydrator(), new ArrayObject()); + $resultSet->initialize(new ArrayIterator([new stdClass()])); + + self::expectException(UnexpectedValueException::class); + self::expectExceptionMessage('will not transform a row it did not create'); + + $resultSet->current(); } #[Test] @@ -191,6 +275,44 @@ public function setRowPrototypeStoresPrototype(): void static::assertSame($prototype, $hydratingRs->getRowPrototype()); } + #[Test] + public function subclassGettersDecideHowRowsAreHydrated(): void + { + $hydratingRs = new class extends HydratingResultSet { + public int $hydratorCalls = 0; + + #[Override] + public function getHydrator(): ClassMethodsHydrator + { + $this->hydratorCalls++; + + return new ClassMethodsHydrator(); + } + + #[Override] + public function getRowPrototype(): object + { + return new class { + public ?int $id = null; + + public function setId(int $id): void + { + $this->id = $id * 10; + } + }; + } + }; + $hydratingRs->initialize(new ArrayIterator([['id' => 1], ['id' => 2]])); + + $ids = []; + foreach ($hydratingRs as $row) { + $ids[] = $row->id; + } + + static::assertSame([10, 20], $ids); + static::assertSame(1, $hydratingRs->hydratorCalls, 'getHydrator() is asked once per data source, not per row'); + } + /** * @throws Exception * @todo Implement testToArray(). @@ -207,6 +329,18 @@ public function toArray(): void static::assertIsArray($obj); } + #[Test] + public function toArrayReportsARowTheHydratorCannotExtract(): void + { + $hydratingRs = new HydratingResultSet(); + $hydratingRs->initialize(new ArrayIterator([1, 2])); + + self::expectException(UnexpectedValueException::class); + self::expectExceptionMessage('A row of type "int"'); + + $hydratingRs->toArray(); + } + #[Test] public function toArrayUsesHydratorExtract(): void { diff --git a/test/unit/ResultSet/ObjectResultSetTest.php b/test/unit/ResultSet/ObjectResultSetTest.php new file mode 100644 index 00000000..d0f50071 --- /dev/null +++ b/test/unit/ResultSet/ObjectResultSetTest.php @@ -0,0 +1,136 @@ + + */ + public static function nonObjectRowProvider(): array + { + return [ + 'an array row means the fetch mode yields arrays' => [['id' => 1], 'array'], + 'FETCH_BOUND yields a success flag' => [true, 'bool'], + ]; + } + + #[Test] + #[DataProvider('nonObjectRowProvider')] + public function currentRejectsARowThatIsNotAnObject(mixed $row, string $rowType): void + { + $resultSet = new ObjectResultSet(); + $resultSet->initialize(new ArrayIterator([$row])); + + self::expectException(UnexpectedValueException::class); + self::expectExceptionMessage(sprintf('A row of type "%s"', $rowType)); + + $resultSet->current(); + } + + #[Test] + public function currentRepeatsTheSameInstanceWhileBuffered(): void + { + $row = new stdClass(); + $row->id = 1; + $resultSet = new ObjectResultSet(); + $resultSet->initialize(new ArrayIterator([$row])); + $resultSet->buffer(); + + $first = $resultSet->current(); + $resultSet->rewind(); + + static::assertSame($first, $resultSet->current()); + } + + #[Test] + public function currentReturnsNullOnceTheRowsAreExhausted(): void + { + $resultSet = new ObjectResultSet(); + $resultSet->initialize(new ArrayIterator([])); + + static::assertNull($resultSet->current()); + } + + #[Test] + public function currentReturnsTheVeryObjectTheDataSourceGave(): void + { + $row = new stdClass(); + $row->id = 1; + $row->name = 'one'; + + $resultSet = new ObjectResultSet(); + $resultSet->initialize(new ArrayIterator([$row])); + + static::assertSame($row, $resultSet->current()); + } + + #[Test] + public function currentSaysWhichTypesItAccepts(): void + { + $resultSet = new ObjectResultSet(); + $resultSet->initialize(new ArrayIterator([['id' => 1]])); + + self::expectException(UnexpectedValueException::class); + self::expectExceptionMessage('which accepts an object'); + + $resultSet->current(); + } + + #[Test] + public function toArrayReadsARowCarryingItsValuesAsElements(): void + { + $resultSet = new ObjectResultSet(); + $resultSet->initialize(new ArrayIterator([new ArrayObject(['id' => 1, 'name' => 'one'])])); + + static::assertSame([['id' => 1, 'name' => 'one']], $resultSet->toArray()); + } + + #[Test] + public function toArrayReadsThePublicPropertiesOfEachRow(): void + { + $first = new stdClass(); + $first->id = 1; + $second = new stdClass(); + $second->id = 2; + + $resultSet = new ObjectResultSet(); + $resultSet->initialize(new ArrayIterator([$first, $second])); + + static::assertSame([['id' => 1], ['id' => 2]], $resultSet->toArray()); + } + + #[Test] + public function toArrayRejectsARowThatExposesNothing(): void + { + $resultSet = new ObjectResultSet(); + $resultSet->initialize(new ArrayIterator([new stdClass()])); + + self::expectException(UnexpectedValueException::class); + self::expectExceptionMessage('exposes no values'); + + $resultSet->toArray(); + } +} diff --git a/test/unit/ResultSet/ResultSetFetchModeTest.php b/test/unit/ResultSet/ResultSetFetchModeTest.php new file mode 100644 index 00000000..f239aacc --- /dev/null +++ b/test/unit/ResultSet/ResultSetFetchModeTest.php @@ -0,0 +1,211 @@ + + */ + public static function objectYieldingModeProvider(): array + { + return [ + 'FETCH_OBJ yields a stdClass' => [PDO::FETCH_OBJ, stdClass::class], + 'FETCH_LAZY yields a PDORow' => [PDO::FETCH_LAZY, PDORow::class], + ]; + } + + /** + * Modes whose rows are not row data. The result set will not transform them, so each + * names the type the caller is left holding. + * + * @return array + */ + public static function refusedModeProvider(): array + { + return [ + 'FETCH_OBJ yields a stdClass' => [PDO::FETCH_OBJ, 'stdClass'], + 'FETCH_LAZY yields a PDORow' => [PDO::FETCH_LAZY, 'PDORow'], + 'FETCH_BOUND yields true and binds by reference' => [PDO::FETCH_BOUND, 'bool'], + ]; + } + + /** + * Every mode that yields row data, all of which fill the ArrayObject prototype. + * + * @return array + */ + public static function rowYieldingModeProvider(): array + { + return [ + 'FETCH_ASSOC' => [PDO::FETCH_ASSOC], + 'FETCH_NUM' => [PDO::FETCH_NUM], + 'FETCH_BOTH' => [PDO::FETCH_BOTH], + 'FETCH_NAMED' => [PDO::FETCH_NAMED], + 'FETCH_KEY_PAIR' => [PDO::FETCH_KEY_PAIR], + 'FETCH_PROPS_LATE is a flag, so PDO falls back' => [PDO::FETCH_PROPS_LATE], + 'FETCH_CLASSTYPE is a flag, so PDO falls back' => [PDO::FETCH_CLASSTYPE], + ]; + } + + /** + * Modes Result::setFetchMode() accepts but cannot actually drive, because it takes + * only an int and PDO needs more. Documented here so the gap stays visible. + * + * @return array + */ + public static function unusableModeProvider(): array + { + return [ + 'FETCH_CLASS needs a class name PDO is never given' => [PDO::FETCH_CLASS], + 'FETCH_INTO needs a target object PDO is never given' => [PDO::FETCH_INTO], + 'FETCH_FUNC is rejected by PDO outside fetchAll()' => [PDO::FETCH_FUNC], + ]; + } + + /** + * FETCH_BOUND carries its row in variables bound by reference and yields only a + * success flag, so the columns still arrive even though the row itself is refused. + */ + #[Test] + public function boundFetchModeStillPopulatesTheBoundColumns(): void + { + $result = new Result(); + $result->initialize($this->pdo->query('SELECT id, name FROM t'), null); + $result->setFetchMode(PDO::FETCH_BOUND); + + $id = null; + $resource = $result->getResource(); + $resource->bindColumn(1, $id); + + $resultSet = new ResultSet(); + $resultSet->initialize($result); + + self::expectException(UnexpectedValueException::class); + + try { + iterator_to_array($resultSet, preserve_keys: false); + } finally { + static::assertSame(1, (int) $id); + } + } + + #[Test] + #[DataProvider('rowYieldingModeProvider')] + public function everyRowYieldingFetchModeFillsThePrototype(int $fetchMode): void + { + static::assertContainsOnlyInstancesOf(ArrayObject::class, $this->rowsFor($fetchMode)); + } + + #[Test] + #[DataProvider('rowYieldingModeProvider')] + public function everyRowYieldingFetchModeReturnsBothRows(int $fetchMode): void + { + static::assertCount(2, $this->rowsFor($fetchMode)); + } + + #[Test] + #[DataProvider('unusableModeProvider')] + public function fetchModesNeedingExtraArgumentsCannotBeDriven(int $fetchMode): void + { + $this->expectException(Throwable::class); + + $this->rowsFor($fetchMode); + } + + #[Test] + #[DataProvider('refusedModeProvider')] + public function fetchModesYieldingSomethingOtherThanRowDataAreRefused( + int $fetchMode, + string $rowType, + ): void { + self::expectException(UnexpectedValueException::class); + self::expectExceptionMessage(sprintf('A row of type "%s"', $rowType)); + + $this->rowsFor($fetchMode); + } + + #[Test] + public function objectResultSetKeepsTheColumnValuesOfAnObjectRow(): void + { + $rows = $this->rowsFor(PDO::FETCH_OBJ, new ObjectResultSet()); + + static::assertSame([1, 2], array_map(static fn(object $row): int => (int) $row->id, $rows)); + } + + /** + * The modes ResultSet refuses are the ones ObjectResultSet exists for; it yields the + * caller's own objects rather than reshaping them. + */ + #[Test] + #[DataProvider('objectYieldingModeProvider')] + public function objectYieldingFetchModesAreServedByObjectResultSet( + int $fetchMode, + string $rowType, + ): void { + $rows = $this->rowsFor($fetchMode, new ObjectResultSet()); + + static::assertCount(2, $rows); + static::assertContainsOnlyInstancesOf($rowType, $rows); + } + + protected function setUp(): void + { + $this->pdo = new PDO('sqlite::memory:'); + $this->pdo->exec('CREATE TABLE t (id INTEGER, name TEXT)'); + $this->pdo->exec('INSERT INTO t VALUES (1, "one"), (2, "two")'); + } + + /** @return list */ + private function rowsFor(int $fetchMode, ?ResultSetInterface $resultSet = null): array + { + $result = new Result(); + $result->initialize($this->pdo->query('SELECT id, name FROM t'), null); + $result->setFetchMode($fetchMode); + + $resultSet ??= new ResultSet(); + $resultSet->initialize($result); + + return iterator_to_array($resultSet, preserve_keys: false); + } +} diff --git a/test/unit/ResultSet/ResultSetIntegrationTest.php b/test/unit/ResultSet/ResultSetIntegrationTest.php index b1bfd67b..814a3a85 100644 --- a/test/unit/ResultSet/ResultSetIntegrationTest.php +++ b/test/unit/ResultSet/ResultSetIntegrationTest.php @@ -9,7 +9,9 @@ use Override; use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\ResultSet\AbstractResultSet; +use PhpDb\ResultSet\Exception\InvalidArgumentException; use PhpDb\ResultSet\Exception\RuntimeException; +use PhpDb\ResultSet\Exception\UnexpectedValueException; use PhpDb\ResultSet\ResultSet; use PhpDb\ResultSet\ResultSetReturnType; use PHPUnit\Framework\Attributes\CoversMethod; @@ -23,13 +25,17 @@ use stdClass; use TypeError; +use function count; +use function get_debug_type; use function is_array; use function random_int; use function var_export; -#[CoversMethod(AbstractResultSet::class, 'current')] +#[CoversMethod(AbstractResultSet::class, 'currentRow')] +#[CoversMethod(AbstractResultSet::class, 'resolveIterator')] #[CoversMethod(AbstractResultSet::class, 'buffer')] #[CoversMethod(ResultSet::class, 'current')] +#[CoversMethod(ResultSet::class, 'mapRow')] #[CoversMethod(ResultSet::class, 'getReturnType')] #[CoversMethod(ResultSet::class, '__construct')] #[CoversMethod(ResultSet::class, 'getArrayObjectPrototype')] @@ -37,12 +43,13 @@ #[CoversMethod(ResultSet::class, 'setArrayObjectPrototype')] #[CoversMethod(ResultSet::class, 'setRowPrototype')] #[CoversMethod(ResultSet::class, 'toArray')] +#[CoversMethod(ResultSet::class, 'resetResolvedConfiguration')] +#[CoversMethod(AbstractResultSet::class, 'resetResolvedConfiguration')] #[Group('unit')] final class ResultSetIntegrationTest extends TestCase { protected ResultSet $resultSet; - /** @psalm-return array */ public static function invalidReturnTypes(): array { return [ @@ -55,6 +62,104 @@ public static function invalidReturnTypes(): array ]; } + /** @psalm-return array */ + /** + * Every case in ResultSetReturnType and the type a row arrives as, so a case + * cannot be added without deciding what current() does with it. + * + * @return array + */ + public static function returnTypeProvider(): array + { + $cases = [ + 'ArrayObject fills the row prototype' => [ResultSetReturnType::ArrayObject, ArrayObject::class], + 'Prototype fills the row prototype' => [ResultSetReturnType::Prototype, ArrayObject::class], + 'Array hands back the raw row' => [ResultSetReturnType::Array, 'array'], + ]; + + self::assertSame( + count(ResultSetReturnType::cases()), + count($cases), + 'every ResultSetReturnType case needs a row type asserted here', + ); + + return $cases; + } + + #[Test] + public function aBufferedPassAfterSetRowPrototypeUsesTheNewPrototype(): void + { + $resultSet = new ResultSet(); + $resultSet->initialize(new ArrayIterator([['id' => 1]])); + $resultSet->buffer(); + $resultSet->current(); + + $resultSet->setRowPrototype(new class([], ArrayObject::ARRAY_AS_PROPS) extends ArrayObject {}); + $resultSet->rewind(); + + static::assertNotSame(ArrayObject::class, $resultSet->current()::class); + } + + #[Test] + public function aBufferedResultSetBuildsFreshRowsOnEveryPass(): void + { + $resultSet = new ResultSet(); + $resultSet->initialize(new ArrayIterator([['id' => 1], ['id' => 2]])); + $resultSet->buffer(); + + $first = $resultSet->current(); + $resultSet->rewind(); + + static::assertNotSame($first, $resultSet->current()); + static::assertEquals($first, $resultSet->current()); + } + + #[Test] + public function aChangeToARowInOneBufferedPassDoesNotReachTheNext(): void + { + $this->resultSet->initialize(new ArrayIterator([['id' => 1, 'name' => 'one']])); + $this->resultSet->buffer(); + + $row = $this->resultSet->current(); + static::assertInstanceOf(ArrayObject::class, $row); + $row['name'] = 'ONE'; + $this->resultSet->rewind(); + + static::assertSame(['id' => 1, 'name' => 'one'], $this->resultSet->current()?->getArrayCopy()); + } + + #[Test] + public function aRowPrototypeSetAfterABufferedPassShapesTheNext(): void + { + $this->resultSet->initialize(new ArrayIterator([['id' => 1]])); + $this->resultSet->buffer(); + $this->resultSet->current(); + + $prototype = new class extends ArrayObject {}; + $this->resultSet->setRowPrototype($prototype); + $this->resultSet->rewind(); + + static::assertInstanceOf($prototype::class, $this->resultSet->current()); + } + + #[Test] + public function aSubclassGetRowPrototypeDecidesTheRowClass(): void + { + $resultSet = new class extends ResultSet { + #[Override] + public function getRowPrototype(): ArrayObject + { + return new class([], ArrayObject::ARRAY_AS_PROPS) extends ArrayObject {}; + } + }; + $resultSet->initialize([['id' => 1]]); + + $row = $resultSet->current(); + + static::assertInstanceOf(ArrayObject::class, $row); + static::assertNotSame(ArrayObject::class, $row::class); + } + /** * @throws Exception * @throws \Exception @@ -97,13 +202,14 @@ public function canProvideArrayAsDataSource(): void #[Test] public function canProvideIteratorAggregateAsDataSource(): void { + $iterator = new ArrayIterator([['id' => 1, 'name' => 'one']]); $iteratorAggregate = $this->getMockBuilder('IteratorAggregate') ->onlyMethods(['getIterator']) ->getMock(); - $iteratorAggregate->expects($this->any())->method('getIterator')->willReturn($iteratorAggregate); - // Initialize with IteratorAggregate and verify its iterator is used + $iteratorAggregate->expects($this->any())->method('getIterator')->willReturn($iterator); + // Initialize with IteratorAggregate and verify the iterator it wraps is stored $this->resultSet->initialize($iteratorAggregate); - static::assertSame($iteratorAggregate->getIterator(), $this->resultSet->getDataSource()); + static::assertSame($iterator, $this->resultSet->getDataSource()); } /** @@ -148,6 +254,25 @@ public function currentClonesRowPrototypeOnEachCall(): void static::assertNotSame($first, $second); } + /** + * @throws Exception + * @throws \Exception + */ + #[Test] + public function currentRejectsARowThatIsNotArrayData(): void + { + $mockResult = $this->createMock(ResultInterface::class); + $mockResult->method('current')->willReturn('Not an Array'); + + $this->resultSet->initialize($mockResult); + $this->resultSet->buffer(); + + self::expectException(UnexpectedValueException::class); + self::expectExceptionMessage('A row of type "string"'); + + $this->resultSet->current(); + } + #[Test] public function currentReturnsArrayObjectWhenReturnTypeIsArrayObject(): void { @@ -172,23 +297,6 @@ public function currentReturnsArrayWhenReturnTypeIsArray(): void static::assertSame(1, $current['id']); } - /** - * @throws Exception - * @throws \Exception - */ - #[Test] - public function currentReturnsNullForNonExistingValues(): void - { - $mockResult = $this->createMock(ResultInterface::class); - $mockResult->expects($this->once())->method('current')->willReturn('Not an Array'); - - $this->resultSet->initialize($mockResult); - $this->resultSet->buffer(); - - // Verify current() returns null when data source returns non-array value - static::assertNull($this->resultSet->current()); - } - /** * @throws \Exception */ @@ -196,6 +304,7 @@ public function currentReturnsNullForNonExistingValues(): void public function currentWithBufferingCallsDataSourceCurrentOnce(): void { $mockResult = $this->getMockBuilder(ResultInterface::class)->getMock(); + $mockResult->method('valid')->willReturn(true); $mockResult->expects($this->once())->method('current')->willReturn(['foo' => 'bar']); $this->resultSet->initialize($mockResult); @@ -214,6 +323,16 @@ public function dataSourceIsNullByDefault(): void static::assertNull($this->resultSet->getDataSource()); } + #[Test] + #[DataProvider('returnTypeProvider')] + public function everyReturnTypeYieldsItsRowType(ResultSetReturnType $returnType, string $expected): void + { + $resultSet = new ResultSet($returnType); + $resultSet->initialize([['id' => 1, 'name' => 'one']]); + + static::assertSame($expected, get_debug_type($resultSet->current())); + } + #[Test] public function fieldCountIsZeroWithNoDataSourcePresent(): void { @@ -282,6 +401,21 @@ public function invalidDataSourceRaisesException(mixed $dataSource): void $this->resultSet->initialize($dataSource); } + /** + * @throws \Exception + */ + #[Test] + public function rejectsAnIteratorAggregateThatNeverResolvesToAnIterator(): void + { + $iteratorAggregate = $this->getMockBuilder('IteratorAggregate') + ->onlyMethods(['getIterator']) + ->getMock(); + $iteratorAggregate->expects($this->any())->method('getIterator')->willReturn($iteratorAggregate); + + self::expectException(InvalidArgumentException::class); + $this->resultSet->initialize($iteratorAggregate); + } + #[Test] public function returnTypeIsObjectByDefault(): void { diff --git a/test/unit/ResultSet/RowPrototypeResultSetTest.php b/test/unit/ResultSet/RowPrototypeResultSetTest.php index 4a588c78..06b9cee1 100644 --- a/test/unit/ResultSet/RowPrototypeResultSetTest.php +++ b/test/unit/ResultSet/RowPrototypeResultSetTest.php @@ -5,24 +5,109 @@ namespace PhpDbTest\ResultSet; use ArrayIterator; +use ArrayObject; +use Override; +use PhpDb\ResultSet\AbstractResultSet; +use PhpDb\ResultSet\Exception\UnexpectedValueException; use PhpDb\ResultSet\RowPrototypeInterface; use PhpDb\ResultSet\RowPrototypeResultSet; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use stdClass; +#[CoversMethod(AbstractResultSet::class, 'getArrayData')] +#[CoversMethod(RowPrototypeResultSet::class, 'unsupportedRowError')] #[CoversMethod(RowPrototypeResultSet::class, 'current')] +#[CoversMethod(RowPrototypeResultSet::class, 'mapRow')] #[CoversMethod(RowPrototypeResultSet::class, 'toArray')] +#[CoversMethod(RowPrototypeResultSet::class, '__construct')] +#[CoversMethod(RowPrototypeResultSet::class, 'getRowPrototype')] +#[CoversMethod(RowPrototypeResultSet::class, 'setRowPrototype')] +#[CoversMethod(RowPrototypeResultSet::class, 'resetResolvedConfiguration')] #[Group('unit')] final class RowPrototypeResultSetTest extends TestCase { #[Test] - public function currentReturnsDataUnchangedWhenNotArray(): void + public function aSubclassGetRowPrototypeDecidesTheRowClass(): void + { + $custom = $this->createRowPrototype(); + $resultSet = new class($this->createRowPrototype(), $custom) extends RowPrototypeResultSet { + public function __construct( + RowPrototypeInterface $prototype, + private RowPrototypeInterface $custom, + ) { + parent::__construct($prototype); + } + + #[Override] + public function getRowPrototype(): RowPrototypeInterface + { + return $this->custom; + } + }; + $resultSet->initialize([['id' => 1]]); + + static::assertSame($custom::class, $resultSet->current()::class); + static::assertNotSame($custom, $resultSet->current(), 'the prototype is cloned for each row'); + } + + #[Test] + public function currentPopulatesThePrototypeFromARowCarryingItsValuesAsElements(): void + { + $resultSet = new RowPrototypeResultSet($this->createRowPrototype()); + $resultSet->initialize(new ArrayIterator([new ArrayObject(['id' => 1, 'name' => 'one'])])); + + $current = $resultSet->current(); + + static::assertInstanceOf(RowPrototypeInterface::class, $current); + static::assertSame(['id' => 1, 'name' => 'one'], $current->toArray()); + } + + #[Test] + public function currentRejectsAnObjectRowThatIsNotAPrototype(): void + { + $resultSet = new RowPrototypeResultSet($this->createRowPrototype()); + $row = new stdClass(); + $row->id = 1; + $row->name = 'one'; + $resultSet->initialize(new ArrayIterator([$row])); + + self::expectException(UnexpectedValueException::class); + self::expectExceptionMessage('A row of type "stdClass"'); + + $resultSet->current(); + } + + #[Test] + public function currentRejectsARowThatExposesNothing(): void + { + $resultSet = new RowPrototypeResultSet($this->createRowPrototype()); + $resultSet->initialize(new ArrayIterator([new stdClass()])); + + self::expectException(UnexpectedValueException::class); + self::expectExceptionMessage('an array, ArrayObject or RowPrototypeInterface'); + + $resultSet->current(); + } + + #[Test] + public function currentReturnsARowThatIsAlreadyAPrototypeUntouched(): void { $prototype = $this->createRowPrototype(); + $row = $this->createRowPrototype()->populate(['id' => 1]); $resultSet = new RowPrototypeResultSet($prototype); - $resultSet->initialize(new ArrayIterator([null])); + $resultSet->initialize(new ArrayIterator([$row])); + + static::assertSame($row, $resultSet->current()); + } + + #[Test] + public function currentReturnsNullOnceTheRowsAreExhausted(): void + { + $resultSet = new RowPrototypeResultSet($this->createRowPrototype()); + $resultSet->initialize(new ArrayIterator([])); static::assertNull($resultSet->current()); } @@ -43,6 +128,36 @@ public function currentReturnsPopulatedCloneOfPrototypeForArrayRow(): void static::assertSame(['id' => 1, 'name' => 'one'], $current->toArray()); } + #[Test] + public function iterationYieldsNullForANullRowAndCarriesOn(): void + { + $resultSet = new RowPrototypeResultSet($this->createRowPrototype()); + $resultSet->initialize([['id' => 1], null, ['id' => 3]]); + + $rows = []; + foreach ($resultSet as $row) { + $rows[] = $row?->toArray(); + } + + static::assertSame([['id' => 1], null, ['id' => 3]], $rows); + } + + #[Test] + public function setRowPrototypeReplacesThePrototypeForLaterRows(): void + { + $first = $this->createRowPrototype(); + $second = $this->createRowPrototype(); + $resultSet = new RowPrototypeResultSet($first); + $resultSet->initialize([['id' => 1], ['id' => 2]]); + $resultSet->current(); + + static::assertSame($resultSet, $resultSet->setRowPrototype($second)); + static::assertSame($second, $resultSet->getRowPrototype()); + + $resultSet->next(); + static::assertSame(['id' => 2], $resultSet->current()->toArray()); + } + #[Test] public function toArrayConvertsPrototypeRowsToArrays(): void { diff --git a/test/unit/ResultSet/TestAsset/PassThroughResultSet.php b/test/unit/ResultSet/TestAsset/PassThroughResultSet.php new file mode 100644 index 00000000..67fc8f44 --- /dev/null +++ b/test/unit/ResultSet/TestAsset/PassThroughResultSet.php @@ -0,0 +1,44 @@ +currentRow(); + } + + /** {@inheritDoc} */ + #[Override] + public function toArray(): array + { + $return = []; + foreach ($this as $row) { + $return[] = $row; + } + + return $return; + } + + #[Override] + protected function mapRow(mixed $row): mixed + { + return $row; + } +}