Skip to content

Commit

Permalink
Add error handler around oci_fetch_array to screen for oracle warnings
Browse files Browse the repository at this point in the history
The error handler would screen for oracle warnings from php oci8 driver
and convert them to a truncated query result exception. 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.
  • Loading branch information
amenning committed Apr 3, 2021
1 parent b2befb0 commit 09fd47f
Show file tree
Hide file tree
Showing 3 changed files with 263 additions and 4 deletions.
35 changes: 35 additions & 0 deletions src/Driver/OCI8/Exception/PHPError.php
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Driver\OCI8\Exception;

use Doctrine\DBAL\Driver\AbstractException;

/**
* This exception is used for any PHP error that the
* OCI8 extension doesn't report via oci_error()
*
* @internal
*
* @psalm-immutable
*/
final class PHPError extends AbstractException
{
public static function new(
string $preambleMessage,
int $errno,
string $errstr,
string $errfile,
int $errline
): self {
return new self(sprintf(
'%s Error number: %s, Error String: %s, Error File: %s, Error Line: %s',
$preambleMessage,
$errno,
$errstr,
$errfile,
$errline
));
}
}
64 changes: 60 additions & 4 deletions src/Driver/OCI8/Result.php
Expand Up @@ -5,14 +5,20 @@
namespace Doctrine\DBAL\Driver\OCI8;

use Doctrine\DBAL\Driver\FetchUtils;
use Doctrine\DBAL\Driver\OCI8\Exception\PHPError;
use Doctrine\DBAL\Driver\Result as ResultInterface;

use function oci_cancel;
use function oci_fetch_all;
use function oci_fetch_array;
use function oci_num_fields;
use function oci_num_rows;
use function restore_error_handler;
use function set_error_handler;
use function sprintf;
use function stripos;

use const E_WARNING;
use const OCI_ASSOC;
use const OCI_FETCHSTATEMENT_BY_COLUMN;
use const OCI_FETCHSTATEMENT_BY_ROW;
Expand Down Expand Up @@ -115,10 +121,18 @@ public function free(): void
*/
private function fetch(int $mode)
{
return oci_fetch_array(
$this->statement,
$mode | OCI_RETURN_NULLS | OCI_RETURN_LOBS
);
set_error_handler([$this, 'handleError'], E_WARNING);

try {
$result = oci_fetch_array(
$this->statement,
$mode | OCI_RETURN_NULLS | OCI_RETURN_LOBS
);
} finally {
restore_error_handler();
}

return $result;
}

/**
Expand All @@ -136,4 +150,46 @@ private function fetchAll(int $mode, int $fetchStructure): array

return $result;
}

/**
* Screen PHP error messages for any error that the OCI8 extension
* doesn't report via oci_error()
*
* @link http://www.dba-oracle.com/t_error_code_list.htm
*/
private static function handleError(
int $errno,
string $errstr,
string $errfile,
int $errline
): bool {
if (preg_match_all('/ORA-(\d+)/', $errstr, $matches) === 0) {
// If no ORA error codes are present in error message,
// skip this error and bubble this error up to
// the normal error handler
return false;
}

foreach ($matches[1] as $oraErrorCode) {
switch ($oraErrorCode) {
case '04061':
case '04065':
case '04068':
throw PHPError::new(
'There was an error before all rows could be fetched.',
$errno,
$errstr,
$errfile,
$errline
);
default:
null;
}
}

// If ORA error codes in message do not match specified blocklist
// ORA error codes, skip this error and bubble this error up to
// the normal error handler
return false;
}
}
168 changes: 168 additions & 0 deletions tests/Functional/Driver/OCI8/ResultTest.php
@@ -0,0 +1,168 @@
<?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\Exception\DriverException;
use Doctrine\DBAL\Tests\FunctionalTestCase;
use Doctrine\DBAL\Tests\TestUtil;

use function ini_get;
use function sprintf;

/**
* @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
{
$this->expectException(DriverException::class);
$this->expectErrorMessageMatches(
'/^An exception occurred in the driver: There was an error before all rows could be fetched. '
. 'Error number: 2, Error String: oci_fetch_array.*/'
);

// 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;
}
}

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 09fd47f

Please sign in to comment.