Skip to content

Commit

Permalink
Add failing unit test for truncated fetch issue
Browse files Browse the repository at this point in the history
This adds a test that demonstrates the issue where a result set
larger than the oci8 default prefetch value is queried, but a truncated
row set is returned due to the dataset being invalidated during the
fetch sequenece.  No php exception is thrown.
  • Loading branch information
amenning committed Apr 2, 2021
1 parent ba7bf25 commit dda2abd
Showing 1 changed file with 179 additions and 0 deletions.
179 changes: 179 additions & 0 deletions tests/Functional/Driver/OCI8/ResultTest.php
@@ -0,0 +1,179 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Tests\Functional\Driver\OCI8;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Driver\OCI8\Driver;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Tests\FunctionalTestCase;
use Doctrine\DBAL\Tests\TestUtil;

use function error_reporting;
use function ini_get;
use function sprintf;

use const E_ALL;
use const E_WARNING;

/**
* @requires extension oci8
*/
class ResultTest extends FunctionalTestCase
{
/**
* Database connection parameters for functional test case
*
* @var array<string,mixed>
*/
private $connectionParams;

protected function setUp(): void
{
parent::setUp();
$this->connectionParams = TestUtil::getConnectionParams();

if ($this->connection->getDriver() instanceof Driver) {
return;
}

self::markTestSkipped('oci8 only test.');
}

protected function tearDown(): void
{
$this->connection->executeQuery(sprintf(
'DROP FUNCTION %s.test_oracle_fetch_failure',
$this->connectionParams['user']
));
$this->connection->executeQuery(sprintf(
'DROP TYPE %s.return_numbers',
$this->connectionParams['user']
));

parent::tearDown();
}

/**
* This test will recreate the case where a data set that is larger than the
* oci8 default prefetch is invalidated on the database after a fetch has begun,
* but before the fetch has completed.
*
* Note that this test requires 2 separate user connections so that the
* pipelined function can be changed mid fetch.
*/
public function testTruncatedFetch(): void
{
// Disabled warning reporting to prevent PHPUnit
// from converting PHP E_WARNING from PHP oci8 driver to exception
error_reporting(E_ALL & ~E_WARNING);

// Create a pipelined funtion that returns 10 rows more than the
// oci8 default prefetch
$this->createReturnTypeNeededForPipelinedFunction();
$expectedTotalRowCount = (int) ini_get('oci8.default_prefetch') + 10;
$this->createPipelinedFunction($expectedTotalRowCount);

// Create a separate connection from that used to create/update the function
// This must be a different user with permissions to change the given function
$separateConnection = $this->createSeparateConnection([
'user' => 'system',
'password' => 'oracle',
]);

// Query the pipelined function to get initial dataset
$statement = $separateConnection->prepare(sprintf(
'SELECT * FROM TABLE(%s.test_oracle_fetch_failure())',
$this->connectionParams['user']
));
$result = $statement->execute();

// Access the first result to cause the first X rows to be prefetched
// as defined by oci8.default_prefetch (often 100 rows)
$firstNumber = $result->fetchOne();

// Invalidate the original dataset by changing the pipelined function
// after the initial prefetch that caches locally the first X results
$this->createPipelinedFunction($expectedTotalRowCount + 10);

while ($number = $result->fetchOne()) {
// Attempt to access all remaining rows from the original fetch
// The rows locally cached from the default prefetch will first be used
// but when the result attempts to get the remaining 10 rows beyond
// the first prefetch, nothing will be returned
//
// PHP oci8 oci_fetch_array will issue a PHP E_WARNING when the 2nd prefetch occurs
// oci_fetch_array(): ORA-04068: existing state of packages has been discarded
// ORA-04061: existing state of function "ROOT.TEST_ORACLE_FETCH_FAILURE" has been invalidated
// ORA-04065: not executed, altered or dropped function "ROOT.TEST_ORACLE_FETCH_FAILURE"
//
// If there was no issue, this should have returned rows totalling 10
// higher than the oci8 default prefetch
continue;
}

self::assertEquals(
$expectedTotalRowCount,
$result->rowCount(),
sprintf(
'Expected to fetch %s rows but only %s rows were fetched and no exception was thrown',
$expectedTotalRowCount,
$result->rowCount(),
)
);
}

private function createReturnTypeNeededForPipelinedFunction(): void
{
$this->connection->executeQuery(sprintf(
'CREATE TYPE %s.return_numbers AS TABLE OF NUMBER(11)',
$this->connectionParams['user']
));
}

/**
* This will create a pipelined function that returns X rows with
* each row returning a single column_value of that row's row number.
* The total number of rows returned is equal to $totalRowCount.
*/
private function createPipelinedFunction(int $totalRowCount): void
{
$this->connection->executeQuery(sprintf(
'CREATE OR REPLACE FUNCTION %s.test_oracle_fetch_failure
RETURN %s.return_numbers PIPELINED
AS
v_number_list return_numbers;
BEGIN
SELECT ROWNUM r
BULK COLLECT INTO v_number_list
FROM DUAL
CONNECT BY ROWNUM <= %d;
FOR i IN 1 .. v_number_list.COUNT
LOOP
PIPE ROW (v_number_list(i));
END LOOP;
RETURN;
END;',
$this->connectionParams['user'],
$this->connectionParams['user'],
$totalRowCount
));
}

/**
* @param array<string,string> $userParams
*/
private function createSeparateConnection(array $userParams): Connection
{
$separateConnectionParams = $this->connectionParams;
$separateConnectionParams['user'] = $userParams['user'];
$separateConnectionParams['password'] = $userParams['password'];

return DriverManager::getConnection(
$separateConnectionParams
);
}
}

0 comments on commit dda2abd

Please sign in to comment.