diff --git a/docs/book/adapter.md b/docs/book/adapter.md index be8f7eee..967f6695 100644 --- a/docs/book/adapter.md +++ b/docs/book/adapter.md @@ -78,12 +78,22 @@ class Adapter implements AdapterInterface, Profiler\ProfilerAwareInterface, Sche public function getQueryResultSetPrototype(): ResultSet\ResultSetInterface; public function getCurrentSchema(): string|false; + /** @deprecated Use prepareQuery() and executeQuery() instead. */ public function query( string $sql, ParameterContainer|array|string $parametersOrQueryMode = self::QUERY_MODE_PREPARE, ?ResultSet\ResultSetInterface $resultPrototype = null ): Driver\StatementInterface|ResultSet\ResultSet|Driver\ResultInterface; + public function prepareQuery( + string $sql, + ParameterContainer|array $parameters = [] + ): Driver\StatementInterface; + + public function executeQuery( + string|Driver\StatementInterface $sql + ): Driver\ResultInterface; + public function createStatement( ?string $initialSql = null, ParameterContainer|array|null $initialParameters = null @@ -91,6 +101,10 @@ class Adapter implements AdapterInterface, Profiler\ProfilerAwareInterface, Sche } ``` +> **Note:** `prepareQuery()` and `executeQuery()` are currently declared on +> the `Adapter` class only; `AdapterInterface` still declares `query()` alone +> to avoid breaking existing implementors during the 0.x series. + ### Constructor Parameters - **`$driver`**: A `DriverInterface` implementation from a driver package @@ -104,13 +118,12 @@ class Adapter implements AdapterInterface, Profiler\ProfilerAwareInterface, Sche ## Query Preparation -By default, `PhpDb\Adapter\Adapter::query()` prefers that you use -"preparation" as a means for processing SQL statements. This generally means -that you will supply a SQL statement containing placeholders for the values, and -separately provide substitutions for those placeholders: +`PhpDb\Adapter\Adapter::prepareQuery()` prepares a SQL statement, optionally +binding parameters, and always returns the prepared `Statement` without +executing it: -```php title="Query with Prepared Statement" -$adapter->query('SELECT * FROM `artist` WHERE `id` = ?', [5]); +```php title="Preparing a Statement" +$statement = $adapter->prepareQuery('SELECT * FROM `artist` WHERE `id` = ?', [5]); ``` The above example will go through the following steps: @@ -118,37 +131,72 @@ The above example will go through the following steps: 1. Create a new `Statement` object 2. Prepare the array `[5]` into a `ParameterContainer` if necessary 3. Inject the `ParameterContainer` into the `Statement` object -4. Execute the `Statement` object, producing a `Result` object -5. Check the `Result` object to check if the supplied SQL was a result set - producing statement. If the query produced a result set, clone the - `ResultSet` prototype, inject the `Result` as its datasource, and return - the new `ResultSet` instance. Otherwise, return the `Result`. +4. Prepare the `Statement` object and return it + +To actually run the statement, pass it to `executeQuery()`: + +```php title="Executing a Prepared Statement" +$result = $adapter->executeQuery($statement); +``` + +`executeQuery()` always returns the raw `Driver\ResultInterface` — it never +wraps it. If you want the result wrapped in a `ResultSet`, check +`isQueryResult()` and call `getQueryResult()` yourself: + +```php title="Wrapping a Query Result" +if ($result->isQueryResult()) { + $resultSet = $result->getQueryResult(); +} +``` + +`getQueryResult()` clones the `ResultSet` prototype you pass it (or a +default prototype if you pass none), injects the `Result` as its data +source, and returns the new `ResultSet` instance. See +[`getQueryResult()`](#using-the-driver-object) below for details. ## Query Execution -In some cases, you have to execute statements directly without preparation. One +In some cases, you have to execute SQL directly without preparation. One possible reason for doing so would be to execute a DDL statement, as most extensions and RDBMS systems are incapable of preparing such statements. -To execute a query without the preparation step, pass a flag as -the second argument indicating execution is required: +Pass the raw SQL string to `executeQuery()` to execute it without a +preparation step: ```php title="Executing DDL Statement Without Preparation" +$adapter->executeQuery( + 'ALTER TABLE ADD INDEX(`foo_index`) ON (`foo_column`)' +); +``` + +## The Deprecated query() Method + +`Adapter::query()` predates the `prepareQuery()`/`executeQuery()` split and +combines both concerns behind a single method and a stringly-typed second +argument. It is deprecated in favour of the methods above but remains +available, proxying to them internally, for backwards compatibility: + +```php title="Query with Prepared Statement (deprecated)" +$adapter->query('SELECT * FROM `artist` WHERE `id` = ?', [5]); +``` + +```php title="Executing DDL Statement Without Preparation (deprecated)" $adapter->query( 'ALTER TABLE ADD INDEX(`foo_index`) ON (`foo_column`)', Adapter::QUERY_MODE_EXECUTE ); ``` -The primary difference to notice is that you must provide the -`Adapter::QUERY_MODE_EXECUTE` (execute) flag as the second parameter. +The primary difference to notice in the second example is that you must +provide the `Adapter::QUERY_MODE_EXECUTE` (execute) flag as the second +parameter. ## Creating Statements -While `query()` is highly useful for one-off and quick querying of a database -via the `Adapter`, it generally makes more sense to create a statement and -interact with it directly, so that you have greater control over the -prepare-then-execute workflow: +While `prepareQuery()` and `executeQuery()` are highly useful for one-off and +quick querying of a database via the `Adapter`, it generally makes more sense +to create a statement and interact with it directly, so that you have +greater control over the prepare-then-execute workflow: ```php title="Creating and Executing a Statement" $statement = $adapter->createStatement($sql, $optionalParameters); @@ -229,6 +277,7 @@ interface ResultInterface extends Countable, Iterator { public function buffer(): void; public function isQueryResult(): bool; + public function getQueryResult(?ResultSetInterface $resultPrototype = null): ResultSetInterface; public function getAffectedRows(): int; public function getGeneratedValue(): mixed; public function getResource(): mixed; @@ -236,6 +285,14 @@ interface ResultInterface extends Countable, Iterator } ``` +`getQueryResult()` clones the given `$resultPrototype` (or a default +`ResultSet` prototype if none is given), initializes the clone from the +result, and returns it. It throws `Exception\RuntimeException` if +`isQueryResult()` is false. `Adapter::query()` (the deprecated BC method) +delegates to this method rather than duplicating the clone-and-initialize +logic itself; call it yourself when you want a `ResultSet` from +`executeQuery()`'s raw `Driver\ResultInterface`. + ## Using The Platform Object The `Platform` object provides an API to assist in crafting queries in a way @@ -442,7 +499,7 @@ $sql = 'UPDATE ' . $qi('artist') . ' SET ' . $qi('name') . ' = ' . $fp('name') . ' WHERE ' . $qi('id') . ' = ' . $fp('id'); -$statement = $adapter->query($sql); +$statement = $adapter->prepareQuery($sql); $parameters = [ 'name' => 'Updated Artist', @@ -452,7 +509,7 @@ $parameters = [ $statement->execute($parameters); // DATA UPDATED, NOW CHECK -$statement = $adapter->query( +$statement = $adapter->prepareQuery( 'SELECT * FROM ' . $qi('artist') . ' WHERE id = ' . $fp('id') diff --git a/src/Adapter/Adapter.php b/src/Adapter/Adapter.php index 6a19e00b..1c3fde99 100644 --- a/src/Adapter/Adapter.php +++ b/src/Adapter/Adapter.php @@ -9,9 +9,7 @@ use PhpDb\ResultSet; use function func_get_args; -use function in_array; use function is_array; -use function is_string; use function strtolower; class Adapter implements AdapterInterface, Profiler\ProfilerAwareInterface, SchemaAwareInterface @@ -73,7 +71,10 @@ public function getCurrentSchema(): string|false /** * query() is a convenience function * + * @deprecated Use prepareQuery() and executeQuery() instead. query() will be removed in a future version. + * * @throws Exception\InvalidArgumentException + * @throws Exception\RuntimeException When execution did not produce a result. * @throws PhpException */ #[Override] @@ -82,45 +83,69 @@ public function query( ParameterContainer|array|string $parametersOrQueryMode = self::QUERY_MODE_PREPARE, ?ResultSet\ResultSetInterface $resultPrototype = null ): Driver\StatementInterface|ResultSet\ResultSetInterface|Driver\ResultInterface { - if ( - is_string($parametersOrQueryMode) - && in_array($parametersOrQueryMode, [self::QUERY_MODE_PREPARE, self::QUERY_MODE_EXECUTE]) - ) { - $mode = $parametersOrQueryMode; - $parameters = null; - } elseif (is_array($parametersOrQueryMode) || $parametersOrQueryMode instanceof ParameterContainer) { - $mode = self::QUERY_MODE_PREPARE; - $parameters = $parametersOrQueryMode; - } else { - throw new Exception\InvalidArgumentException( - 'Parameter 2 to this method must be a flag, an array, or ParameterContainer' - ); + if ($parametersOrQueryMode === self::QUERY_MODE_PREPARE) { + return $this->prepareQuery($sql); } - if ($mode === self::QUERY_MODE_PREPARE) { - $lastPreparedStatement = $this->driver->createStatement($sql); - $lastPreparedStatement->prepare(); - if (is_array($parameters) || $parameters instanceof ParameterContainer) { - if (is_array($parameters)) { - $lastPreparedStatement->setParameterContainer(new ParameterContainer($parameters)); - } else { - $lastPreparedStatement->setParameterContainer($parameters); - } - $result = $lastPreparedStatement->execute(); - } else { - return $lastPreparedStatement; - } - } else { - $result = $this->driver->getConnection()->execute($sql); + $sql = match (true) { + $parametersOrQueryMode === self::QUERY_MODE_EXECUTE + => $sql, + $parametersOrQueryMode instanceof ParameterContainer, + is_array($parametersOrQueryMode) + => $this->prepareQuery($sql, $parametersOrQueryMode), + default => throw new Exception\InvalidArgumentException( + 'Flag incorrectly set' + ), + }; + + $result = $this->executeQuery($sql); + + return $result->isQueryResult() + ? $result->getQueryResult($resultPrototype ?? $this->queryResultSetPrototype) + : $result; + } + + /** + * Prepare a statement for the given SQL, optionally binding parameters. + * + * Always prepares the statement; never executes it. Use executeQuery() + * to run the returned statement. + */ + #[Override] + public function prepareQuery( + string $sql, + ParameterContainer|array $parameters = [] + ): Driver\StatementInterface { + $statement = $this->driver->createStatement($sql); + + if (is_array($parameters)) { + $parameters = new ParameterContainer($parameters); } - if ($result instanceof Driver\ResultInterface && $result->isQueryResult()) { - $resultSet = $resultPrototype ?? $this->queryResultSetPrototype; - $resultSetCopy = clone $resultSet; + $statement->setParameterContainer($parameters); + $statement->prepare(); + + return $statement; + } - $resultSetCopy->initialize($result); + /** + * Execute raw SQL or a prepared statement. + * + * Narrows the driver's execution result to a Driver\ResultInterface, + * never a wrapped ResultSet. Callers can check isQueryResult() and use + * getQueryResult() themselves if they want the result wrapped. + * + * @throws Exception\RuntimeException When execution did not produce a result. + */ + #[Override] + public function executeQuery(Driver\StatementInterface|string $sql): Driver\ResultInterface + { + $result = $sql instanceof Driver\StatementInterface + ? $sql->execute() + : $this->driver->getConnection()->execute($sql); - return $resultSetCopy; + if (! $result instanceof Driver\ResultInterface) { + throw new Exception\RuntimeException('Query execution did not produce a result'); } return $result; diff --git a/src/Adapter/AdapterInterface.php b/src/Adapter/AdapterInterface.php index 185aa110..9e214ef3 100644 --- a/src/Adapter/AdapterInterface.php +++ b/src/Adapter/AdapterInterface.php @@ -49,6 +49,21 @@ public function query( ?ResultSet\ResultSetInterface $resultPrototype = null ): Driver\StatementInterface|ResultSet\ResultSetInterface|Driver\ResultInterface; + /** + * Prepares a statement for the given SQL without executing it. + */ + public function prepareQuery( + string $sql, + ParameterContainer|array $parameters = [] + ): Driver\StatementInterface; + + /** + * Executes raw SQL or a prepared statement. + * + * @throws Exception\RuntimeException When execution did not produce a result. + */ + public function executeQuery(Driver\StatementInterface|string $sql): Driver\ResultInterface; + /** * @todo 0.3.x track down this usage!!! * @return array diff --git a/src/Adapter/Driver/Pdo/Result.php b/src/Adapter/Driver/Pdo/Result.php index 54d37647..40875cd2 100644 --- a/src/Adapter/Driver/Pdo/Result.php +++ b/src/Adapter/Driver/Pdo/Result.php @@ -11,6 +11,8 @@ use PDOStatement; use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\Adapter\Exception; +use PhpDb\ResultSet\ResultSet; +use PhpDb\ResultSet\ResultSetInterface; // phpcs:ignore SlevomatCodingStandard.Namespaces.UnusedUses.UnusedUse use ReturnTypeWillChange; @@ -276,6 +278,28 @@ public function isQueryResult(): bool return $this->resource->columnCount() > 0; } + /** + * {@inheritdoc} + * + * @throws Exception\RuntimeException + */ + #[Override] + public function getQueryResult(?ResultSetInterface $resultPrototype = null): ResultSetInterface + { + if (! $this->isQueryResult()) { + throw new Exception\RuntimeException( + 'Cannot produce a query result set from a result that is not a query result;' + . ' check isQueryResult() first' + ); + } + + $resultPrototype ??= new ResultSet(); + $resultSet = clone $resultPrototype; + $resultSet->initialize($this); + + return $resultSet; + } + /** * {@inheritdoc} */ diff --git a/src/Adapter/Driver/ResultInterface.php b/src/Adapter/Driver/ResultInterface.php index 50119cce..bb826e4a 100644 --- a/src/Adapter/Driver/ResultInterface.php +++ b/src/Adapter/Driver/ResultInterface.php @@ -6,6 +6,8 @@ use Countable; use Iterator; +use PhpDb\Adapter\Exception; +use PhpDb\ResultSet\ResultSetInterface; interface ResultInterface extends Countable, @@ -26,6 +28,14 @@ public function isBuffered(): ?bool; */ public function isQueryResult(): bool; + /** + * Get the seeded query result set, cloned from $resultPrototype (or a + * default prototype if none is given) and initialized from this result. + * + * @throws Exception\RuntimeException When isQueryResult() is false. + */ + public function getQueryResult(?ResultSetInterface $resultPrototype = null): ResultSetInterface; + /** * Get affected rows */ diff --git a/src/Sql/Select.php b/src/Sql/Select.php index de499976..ba99374c 100644 --- a/src/Sql/Select.php +++ b/src/Sql/Select.php @@ -762,8 +762,8 @@ public function __clone() } /** - * @return array{0: string, 1: string} - * @phpstan-return array{0: string, 1: string} + * @return array{0: string|null, 1: string} + * @phpstan-return array{0: string|null, 1: string} */ protected function resolveTable( Select|string|array|TableIdentifier|null $table, diff --git a/test/unit/Adapter/AdapterTest.php b/test/unit/Adapter/AdapterTest.php index 233040d5..35585d29 100644 --- a/test/unit/Adapter/AdapterTest.php +++ b/test/unit/Adapter/AdapterTest.php @@ -12,6 +12,7 @@ use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\Adapter\Driver\StatementInterface; use PhpDb\Adapter\Exception\InvalidArgumentException; +use PhpDb\Adapter\Exception\RuntimeException; use PhpDb\Adapter\Exception\VunerablePlatformQuoteException; use PhpDb\Adapter\ParameterContainer; use PhpDb\Adapter\Platform\PlatformInterface; @@ -33,6 +34,8 @@ #[CoversMethod(Adapter::class, 'getQueryResultSetPrototype')] #[CoversMethod(Adapter::class, 'getCurrentSchema')] #[CoversMethod(Adapter::class, 'query')] +#[CoversMethod(Adapter::class, 'prepareQuery')] +#[CoversMethod(Adapter::class, 'executeQuery')] #[CoversMethod(Adapter::class, 'createStatement')] #[CoversMethod(Adapter::class, '__get')] #[CoversMethod(Adapter::class, '__construct')] @@ -132,15 +135,14 @@ public function testQueryWhenPreparedProducesStatement(): void #[Group('#210')] public function testProducedResultSetPrototypeIsDifferentForEachQuery(): void { - $statement = $this->createMock(StatementInterface::class); - $result = $this->createMock(ResultInterface::class); + $result = $this->createMock(ResultInterface::class); - $this->mockDriver->method('createStatement') - ->willReturn($statement); $this->mockStatement->method('execute') ->willReturn($result); $result->method('isQueryResult') ->willReturn(true); + $result->method('getQueryResult') + ->willReturnCallback(static fn (): ResultSetInterface => new ResultSet()); self::assertNotSame( $this->adapter->query('SELECT foo', []), @@ -154,13 +156,11 @@ public function testProducedResultSetPrototypeIsDifferentForEachQuery(): void #[TestDox('unit test: Test query() in prepare mode, with array of parameters, produces a result object')] public function testQueryWhenPreparedWithParameterArrayProducesResult(): void { - $parray = ['bar' => 'foo']; - $sql = 'SELECT foo, :bar'; - $statement = $this->getMockBuilder(StatementInterface::class)->getMock(); - $result = $this->getMockBuilder(ResultInterface::class)->getMock(); - $this->mockDriver->expects($this->any())->method('createStatement') - ->with($sql)->willReturn($statement); + $parray = ['bar' => 'foo']; + $sql = 'SELECT foo, :bar'; + $result = $this->getMockBuilder(ResultInterface::class)->getMock(); $this->mockStatement->expects($this->any())->method('execute')->willReturn($result); + $result->expects($this->any())->method('isQueryResult')->willReturn(false); $r = $this->adapter->query($sql, $parray); self::assertSame($result, $r); @@ -179,6 +179,7 @@ public function testQueryWhenPreparedWithParameterContainerProducesResult(): voi ->with($sql)->willReturn($this->mockStatement); $this->mockStatement->expects($this->any())->method('execute')->willReturn($result); $result->expects($this->any())->method('isQueryResult')->willReturn(true); + $result->expects($this->any())->method('getQueryResult')->willReturn(new ResultSet()); $r = $this->adapter->query($sql, $parameterContainer); self::assertInstanceOf(ResultSet::class, $r); @@ -193,6 +194,7 @@ public function testQueryWhenExecutedProducesAResult(): void $sql = 'SELECT foo'; $result = $this->getMockBuilder(ResultInterface::class)->getMock(); $this->mockConnection->expects($this->any())->method('execute')->with($sql)->willReturn($result); + $result->expects($this->any())->method('isQueryResult')->willReturn(false); $r = $this->adapter->query($sql, AdapterInterface::QUERY_MODE_EXECUTE); self::assertSame($result, $r); @@ -209,6 +211,15 @@ public function testQueryWhenExecutedProducesAResultSetObjectWhenResultIsQuery() $result = $this->getMockBuilder(ResultInterface::class)->getMock(); $this->mockConnection->expects($this->any())->method('execute')->with($sql)->willReturn($result); $result->expects($this->any())->method('isQueryResult')->willReturn(true); + $result->expects($this->any()) + ->method('getQueryResult') + ->willReturnCallback( + static function (?ResultSetInterface $resultPrototype = null): ResultSetInterface { + $resultPrototype ??= new ResultSet(); + + return clone $resultPrototype; + } + ); $r = $this->adapter->query($sql, AdapterInterface::QUERY_MODE_EXECUTE); self::assertInstanceOf(ResultSet::class, $r); @@ -217,6 +228,86 @@ public function testQueryWhenExecutedProducesAResultSetObjectWhenResultIsQuery() self::assertInstanceOf(TemporaryResultSet::class, $r); } + #[TestDox('unit test: Test prepareQuery() prepares a statement without executing it')] + public function testPrepareQueryPreparesStatementWithoutExecuting(): void + { + $this->mockStatement->expects($this->once())->method('prepare'); + $this->mockStatement->expects($this->never())->method('execute'); + + $statement = $this->adapter->prepareQuery('SELECT foo'); + + self::assertSame($this->mockStatement, $statement); + } + + #[TestDox('unit test: Test prepareQuery() binds an array of parameters as a ParameterContainer')] + public function testPrepareQueryBindsParameterArray(): void + { + $this->mockStatement->expects($this->once()) + ->method('setParameterContainer') + ->with(self::callback( + static fn (ParameterContainer $container): bool => $container->getNamedArray() === ['bar' => 'foo'] + )); + + $this->adapter->prepareQuery('SELECT foo, :bar', ['bar' => 'foo']); + } + + #[TestDox('unit test: Test prepareQuery() binds a ParameterContainer directly')] + public function testPrepareQueryBindsParameterContainerDirectly(): void + { + $parameterContainer = new ParameterContainer(['bar' => 'foo']); + + $this->mockStatement->expects($this->once()) + ->method('setParameterContainer') + ->with($parameterContainer); + + $this->adapter->prepareQuery('SELECT foo, :bar', $parameterContainer); + } + + #[TestDox('unit test: Test executeQuery() with raw SQL delegates to connection execute')] + public function testExecuteQueryWithRawSqlDelegatesToConnectionExecute(): void + { + $sql = 'SELECT foo'; + $result = $this->createMock(ResultInterface::class); + $this->mockConnection->expects($this->once())->method('execute')->with($sql)->willReturn($result); + + self::assertSame($result, $this->adapter->executeQuery($sql)); + } + + #[TestDox('unit test: Test executeQuery() with a prepared statement executes the statement')] + public function testExecuteQueryWithStatementExecutesStatement(): void + { + $result = $this->createMock(ResultInterface::class); + $this->mockStatement->expects($this->once())->method('execute')->willReturn($result); + $this->mockConnection->expects($this->never())->method('execute'); + + self::assertSame($result, $this->adapter->executeQuery($this->mockStatement)); + } + + #[TestDox('unit test: Test executeQuery() returns the raw result without wrapping query results')] + public function testExecuteQueryReturnsRawResultWithoutWrappingQueryResults(): void + { + $sql = 'SELECT foo'; + $result = $this->createMock(ResultInterface::class); + + $this->mockConnection->method('execute')->willReturn($result); + $result->expects($this->any())->method('isQueryResult')->willReturn(true); + $result->expects($this->never())->method('getQueryResult'); + + self::assertSame($result, $this->adapter->executeQuery($sql)); + } + + #[TestDox('unit test: Test executeQuery() throws when execution does not produce a result')] + public function testExecuteQueryThrowsWhenExecutionDoesNotProduceAResult(): void + { + $sql = 'SELECT foo'; + $this->mockConnection->method('execute')->willReturn(null); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Query execution did not produce a result'); + + $this->adapter->executeQuery($sql); + } + #[TestDox('unit test: Test createStatement() produces a statement object')] public function testCreateStatementDelegatesToDriver(): void { @@ -284,7 +375,7 @@ public function testConstructorWithProfilerDelegatesToSetProfiler(): void public function testQueryThrowsOnInvalidParameterType(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Parameter 2 to this method must be a flag, an array, or ParameterContainer'); + $this->expectExceptionMessage('Flag incorrectly set'); $this->adapter->query('SELECT 1', 'invalid_mode'); } diff --git a/test/unit/Adapter/Driver/Pdo/ResultTest.php b/test/unit/Adapter/Driver/Pdo/ResultTest.php index ff2f88b0..151ff342 100644 --- a/test/unit/Adapter/Driver/Pdo/ResultTest.php +++ b/test/unit/Adapter/Driver/Pdo/ResultTest.php @@ -9,6 +9,8 @@ use PhpDb\Adapter\Driver\Pdo\Result; use PhpDb\Adapter\Exception\InvalidArgumentException; use PhpDb\Adapter\Exception\RuntimeException; +use PhpDb\ResultSet\ResultSet; +use PhpDbTest\TestAsset\TemporaryResultSet; use PHPUnit\Framework\Attributes\CoversMethod; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -27,6 +29,7 @@ #[CoversMethod(Result::class, 'getResource')] #[CoversMethod(Result::class, 'getFieldCount')] #[CoversMethod(Result::class, 'isQueryResult')] +#[CoversMethod(Result::class, 'getQueryResult')] #[CoversMethod(Result::class, 'getAffectedRows')] #[CoversMethod(Result::class, 'getGeneratedValue')] #[CoversMethod(Result::class, 'rewind')] @@ -291,6 +294,55 @@ public function testIsQueryResultReturnsFalseWhenNoColumns(): void self::assertFalse($result->isQueryResult()); } + public function testGetQueryResultThrowsWhenResultIsNotAQueryResult(): void + { + $stub = $this->createMock(PDOStatement::class); + $stub->method('columnCount')->willReturn(0); + + $result = new Result(); + $result->initialize($stub, null); + + $this->expectException(RuntimeException::class); + $result->getQueryResult(); + } + + public function testGetQueryResultReturnsDefaultResultSetPrototypeWhenNoneGiven(): void + { + $stub = $this->createMock(PDOStatement::class); + $stub->method('columnCount')->willReturn(3); + + $result = new Result(); + $result->initialize($stub, null); + + self::assertInstanceOf(ResultSet::class, $result->getQueryResult()); + } + + public function testGetQueryResultClonesGivenPrototypeRatherThanMutatingIt(): void + { + $stub = $this->createMock(PDOStatement::class); + $stub->method('columnCount')->willReturn(3); + + $result = new Result(); + $result->initialize($stub, null); + $prototype = new TemporaryResultSet(); + + $returned = $result->getQueryResult($prototype); + + self::assertInstanceOf(TemporaryResultSet::class, $returned); + self::assertNotSame($prototype, $returned); + } + + public function testGetQueryResultInitializesReturnedResultSetWithThisResult(): void + { + $stub = $this->createMock(PDOStatement::class); + $stub->method('columnCount')->willReturn(3); + + $result = new Result(); + $result->initialize($stub, null); + + self::assertSame($result->getFieldCount(), $result->getQueryResult()->getFieldCount()); + } + public function testGetAffectedRowsDelegatesToRowCount(): void { $stub = $this->createMock(PDOStatement::class);