diff --git a/src/Driver/IBMDB2/Statement.php b/src/Driver/IBMDB2/Statement.php index 699e236d715..bb9d970f0d3 100644 --- a/src/Driver/IBMDB2/Statement.php +++ b/src/Driver/IBMDB2/Statement.php @@ -10,12 +10,15 @@ use Doctrine\DBAL\Driver\Statement as StatementInterface; use Doctrine\DBAL\ParameterType; use Doctrine\Deprecations\Deprecation; +use Throwable; use function assert; use function db2_bind_param; use function db2_execute; use function error_get_last; use function fclose; +use function fseek; +use function ftell; use function func_num_args; use function is_int; use function is_resource; @@ -28,6 +31,7 @@ use const DB2_LONG; use const DB2_PARAM_FILE; use const DB2_PARAM_IN; +use const SEEK_SET; final class Statement implements StatementInterface { @@ -213,8 +217,28 @@ private function createTemporaryFile() */ private function copyStreamToStream($source, $target): void { + $resetTo = false; + if (stream_get_meta_data($source)['seekable']) { + $resetTo = ftell($source); + } + if (@stream_copy_to_stream($source, $target) === false) { - throw CannotCopyStreamToStream::new(error_get_last()); + $copyToStreamError = error_get_last(); + if ($resetTo !== false) { + try { + fseek($source, $resetTo, SEEK_SET); + } catch (Throwable $e) { + // Swallow, we want the original exception from stream_copy_to_stream + } + } + + throw CannotCopyStreamToStream::new($copyToStreamError); } + + if ($resetTo === false) { + return; + } + + fseek($source, $resetTo, SEEK_SET); } } diff --git a/src/Driver/Mysqli/Statement.php b/src/Driver/Mysqli/Statement.php index fec7c95c3c2..e60ba1d6519 100644 --- a/src/Driver/Mysqli/Statement.php +++ b/src/Driver/Mysqli/Statement.php @@ -19,11 +19,16 @@ use function count; use function feof; use function fread; +use function fseek; +use function ftell; use function func_num_args; use function get_resource_type; use function is_int; use function is_resource; use function str_repeat; +use function stream_get_meta_data; + +use const SEEK_SET; final class Statement implements StatementInterface { @@ -213,15 +218,26 @@ private function bindTypedParameters(): void private function sendLongData(array $streams): void { foreach ($streams as $paramNr => $stream) { - while (! feof($stream)) { - $chunk = fread($stream, 8192); + $resetTo = false; + if (stream_get_meta_data($stream)['seekable']) { + $resetTo = ftell($stream); + } - if ($chunk === false) { - throw FailedReadingStreamOffset::new($paramNr); - } + try { + while (! feof($stream)) { + $chunk = fread($stream, 8192); - if (! $this->stmt->send_long_data($paramNr - 1, $chunk)) { - throw StatementError::new($this->stmt); + if ($chunk === false) { + throw FailedReadingStreamOffset::new($paramNr); + } + + if (! $this->stmt->send_long_data($paramNr - 1, $chunk)) { + throw StatementError::new($this->stmt); + } + } + } finally { + if ($resetTo !== false) { + fseek($stream, $resetTo, SEEK_SET); } } } diff --git a/src/Driver/OCI8/Statement.php b/src/Driver/OCI8/Statement.php index 015a14b7be9..a48f361ac37 100644 --- a/src/Driver/OCI8/Statement.php +++ b/src/Driver/OCI8/Statement.php @@ -9,11 +9,15 @@ use Doctrine\DBAL\ParameterType; use Doctrine\Deprecations\Deprecation; +use function fseek; +use function ftell; use function func_num_args; use function is_int; +use function is_resource; use function oci_bind_by_name; use function oci_execute; use function oci_new_descriptor; +use function stream_get_meta_data; use const OCI_B_BIN; use const OCI_B_BLOB; @@ -21,6 +25,7 @@ use const OCI_D_LOB; use const OCI_NO_AUTO_COMMIT; use const OCI_TEMP_BLOB; +use const SEEK_SET; use const SQLT_CHR; final class Statement implements StatementInterface @@ -34,6 +39,9 @@ final class Statement implements StatementInterface /** @var array */ private array $parameterMap; + /** @var mixed[]|null */ + private ?array $paramResources = null; + private ExecutionMode $executionMode; /** @@ -65,6 +73,10 @@ public function bindValue($param, $value, $type = ParameterType::STRING): bool ); } + if ($type === ParameterType::BINARY || $type === ParameterType::LARGE_OBJECT) { + $this->trackParamResource($value); + } + return $this->bindParam($param, $value, $type); } @@ -164,11 +176,87 @@ public function execute($params = null): ResultInterface $mode = OCI_NO_AUTO_COMMIT; } - $ret = @oci_execute($this->statement, $mode); - if (! $ret) { - throw Error::new($this->statement); + $resourceOffsets = $this->getResourceOffsets(); + try { + $ret = @oci_execute($this->statement, $mode); + if (! $ret) { + throw Error::new($this->statement); + } + } finally { + if ($resourceOffsets !== null) { + $this->restoreResourceOffsets($resourceOffsets); + } } return new Result($this->statement); } + + /** + * Track a binary parameter reference at binding time. These + * are cached for later analysis by the getResourceOffsets. + * + * @param mixed $resource + */ + private function trackParamResource($resource): void + { + if (! is_resource($resource)) { + return; + } + + $this->paramResources ??= []; + $this->paramResources[] = $resource; + } + + /** + * Determine the offset that any resource parameters needs to be + * restored to after the statement is executed. Call immediately + * before execute (not during bindValue) to get the most accurate offset. + * + * @return int[]|null Return offsets to restore if needed. The array may be sparse. + */ + private function getResourceOffsets(): ?array + { + if ($this->paramResources === null) { + return null; + } + + $resourceOffsets = null; + foreach ($this->paramResources as $index => $resource) { + $position = false; + if (stream_get_meta_data($resource)['seekable']) { + $position = ftell($resource); + } + + if ($position === false) { + continue; + } + + $resourceOffsets ??= []; + $resourceOffsets[$index] = $position; + } + + if ($resourceOffsets === null) { + $this->paramResources = null; + } + + return $resourceOffsets; + } + + /** + * Restore resource offsets moved by PDOStatement->execute + * + * @param int[]|null $resourceOffsets The offsets returned by getResourceOffsets. + */ + private function restoreResourceOffsets(?array $resourceOffsets): void + { + if ($resourceOffsets === null || $this->paramResources === null) { + return; + } + + foreach ($resourceOffsets as $index => $offset) { + fseek($this->paramResources[$index], $offset, SEEK_SET); + } + + $this->paramResources = null; + } } diff --git a/src/Driver/PDO/SQLSrv/Statement.php b/src/Driver/PDO/SQLSrv/Statement.php index cb2dfaedb78..0cca635516e 100644 --- a/src/Driver/PDO/SQLSrv/Statement.php +++ b/src/Driver/PDO/SQLSrv/Statement.php @@ -5,16 +5,26 @@ use Doctrine\DBAL\Driver\Exception\UnknownParameterType; use Doctrine\DBAL\Driver\Middleware\AbstractStatementMiddleware; use Doctrine\DBAL\Driver\PDO\Statement as PDOStatement; +use Doctrine\DBAL\Driver\Result; use Doctrine\DBAL\ParameterType; use Doctrine\Deprecations\Deprecation; use PDO; +use function fseek; +use function ftell; use function func_num_args; +use function is_resource; +use function stream_get_meta_data; + +use const SEEK_SET; final class Statement extends AbstractStatementMiddleware { private PDOStatement $statement; + /** @var mixed[]|null */ + private ?array $paramResources = null; + /** @internal The statement can be only instantiated by its driver connection. */ public function __construct(PDOStatement $statement) { @@ -104,6 +114,94 @@ public function bindValue($param, $value, $type = ParameterType::STRING): bool ); } + if ($type === ParameterType::LARGE_OBJECT || $type === ParameterType::BINARY) { + $this->trackParamResource($value); + } + return $this->bindParam($param, $value, $type); } + + /** + * {@inheritDoc} + */ + public function execute($params = null): Result + { + $resourceOffsets = $this->getResourceOffsets(); + try { + return parent::execute($params); + } finally { + if ($resourceOffsets !== null) { + $this->restoreResourceOffsets($resourceOffsets); + } + } + } + + /** + * Track a binary parameter reference at binding time. These + * are cached for later analysis by the getResourceOffsets. + * + * @param mixed $resource + */ + private function trackParamResource($resource): void + { + if (! is_resource($resource)) { + return; + } + + $this->paramResources ??= []; + $this->paramResources[] = $resource; + } + + /** + * Determine the offset that any resource parameters needs to be + * restored to after the statement is executed. Call immediately + * before execute (not during bindValue) to get the most accurate offset. + * + * @return int[]|null Return offsets to restore if needed. The array may be sparse. + */ + private function getResourceOffsets(): ?array + { + if ($this->paramResources === null) { + return null; + } + + $resourceOffsets = null; + foreach ($this->paramResources as $index => $resource) { + $position = false; + if (stream_get_meta_data($resource)['seekable']) { + $position = ftell($resource); + } + + if ($position === false) { + continue; + } + + $resourceOffsets ??= []; + $resourceOffsets[$index] = $position; + } + + if ($resourceOffsets === null) { + $this->paramResources = null; + } + + return $resourceOffsets; + } + + /** + * Restore resource offsets moved by PDOStatement->execute + * + * @param int[]|null $resourceOffsets The offsets returned by getResourceOffsets. + */ + private function restoreResourceOffsets(?array $resourceOffsets): void + { + if ($resourceOffsets === null || $this->paramResources === null) { + return; + } + + foreach ($resourceOffsets as $index => $offset) { + fseek($this->paramResources[$index], $offset, SEEK_SET); + } + + $this->paramResources = null; + } } diff --git a/src/Driver/PDO/Statement.php b/src/Driver/PDO/Statement.php index 64f318d2c2b..ab4661417c2 100644 --- a/src/Driver/PDO/Statement.php +++ b/src/Driver/PDO/Statement.php @@ -7,17 +7,27 @@ use Doctrine\DBAL\Driver\Statement as StatementInterface; use Doctrine\DBAL\ParameterType; use Doctrine\Deprecations\Deprecation; +use PDO; use PDOException; use PDOStatement; use function array_slice; +use function fseek; +use function ftell; use function func_get_args; use function func_num_args; +use function is_resource; +use function stream_get_meta_data; + +use const SEEK_SET; final class Statement implements StatementInterface { private PDOStatement $stmt; + /** @var mixed[]|null */ + private ?array $paramResources = null; + /** @internal The statement can be only instantiated by its driver connection. */ public function __construct(PDOStatement $stmt) { @@ -43,6 +53,9 @@ public function bindValue($param, $value, $type = ParameterType::STRING) } $pdoType = ParameterTypeMap::convertParamType($type); + if ($pdoType === PDO::PARAM_LOB) { + $this->trackParamResource($value); + } try { return $this->stmt->bindValue($param, $value, $pdoType); @@ -126,12 +139,86 @@ public function execute($params = null): ResultInterface ); } + $resourceOffsets = $this->getResourceOffsets(); try { $this->stmt->execute($params); } catch (PDOException $exception) { throw Exception::new($exception); + } finally { + if ($resourceOffsets !== null) { + $this->restoreResourceOffsets($resourceOffsets); + } } return new Result($this->stmt); } + + /** + * Track a binary parameter reference at binding time. These + * are cached for later analysis by the getResourceOffsets. + * + * @param mixed $resource + */ + private function trackParamResource($resource): void + { + if (! is_resource($resource)) { + return; + } + + $this->paramResources ??= []; + $this->paramResources[] = $resource; + } + + /** + * Determine the offset that any resource parameters needs to be + * restored to after the statement is executed. Call immediately + * before execute (not during bindValue) to get the most accurate offset. + * + * @return int[]|null Return offsets to restore if needed. The array may be sparse. + */ + private function getResourceOffsets(): ?array + { + if ($this->paramResources === null) { + return null; + } + + $resourceOffsets = null; + foreach ($this->paramResources as $index => $resource) { + $position = false; + if (stream_get_meta_data($resource)['seekable']) { + $position = ftell($resource); + } + + if ($position === false) { + continue; + } + + $resourceOffsets ??= []; + $resourceOffsets[$index] = $position; + } + + if ($resourceOffsets === null) { + $this->paramResources = null; + } + + return $resourceOffsets; + } + + /** + * Restore resource offsets moved by PDOStatement->execute + * + * @param int[]|null $resourceOffsets The offsets returned by getResourceOffsets. + */ + private function restoreResourceOffsets(?array $resourceOffsets): void + { + if ($resourceOffsets === null || $this->paramResources === null) { + return; + } + + foreach ($resourceOffsets as $index => $offset) { + fseek($this->paramResources[$index], $offset, SEEK_SET); + } + + $this->paramResources = null; + } } diff --git a/src/Driver/PgSQL/Statement.php b/src/Driver/PgSQL/Statement.php index 75af66f3014..6d6abedc2ec 100644 --- a/src/Driver/PgSQL/Statement.php +++ b/src/Driver/PgSQL/Statement.php @@ -10,6 +10,8 @@ use TypeError; use function assert; +use function fseek; +use function ftell; use function func_num_args; use function get_class; use function gettype; @@ -26,6 +28,9 @@ use function pg_send_execute; use function sprintf; use function stream_get_contents; +use function stream_get_meta_data; + +use const SEEK_SET; final class Statement implements StatementInterface { @@ -151,10 +156,24 @@ public function execute($params = null): Result switch ($this->parameterTypes[$parameter]) { case ParameterType::BINARY: case ParameterType::LARGE_OBJECT: + $isResource = is_resource($value); + $resource = $value; + $resetTo = false; + if ($isResource) { + if (stream_get_meta_data($resource)['seekable']) { + $resetTo = ftell($resource); + } + } + $escapedParameters[] = $value === null ? null : pg_escape_bytea( $this->connection, - is_resource($value) ? stream_get_contents($value) : $value, + $isResource ? stream_get_contents($value) : $value, ); + + if ($resetTo !== false) { + fseek($resource, $resetTo, SEEK_SET); + } + break; default: $escapedParameters[] = $value; diff --git a/src/Driver/SQLSrv/Statement.php b/src/Driver/SQLSrv/Statement.php index 227c33456c6..242ff1d870e 100644 --- a/src/Driver/SQLSrv/Statement.php +++ b/src/Driver/SQLSrv/Statement.php @@ -10,15 +10,20 @@ use Doctrine\Deprecations\Deprecation; use function assert; +use function fseek; +use function ftell; use function func_num_args; use function is_int; +use function is_resource; use function sqlsrv_execute; use function SQLSRV_PHPTYPE_STREAM; use function SQLSRV_PHPTYPE_STRING; use function sqlsrv_prepare; use function SQLSRV_SQLTYPE_VARBINARY; +use function stream_get_meta_data; use function stripos; +use const SEEK_SET; use const SQLSRV_ENC_BINARY; use const SQLSRV_ENC_CHAR; use const SQLSRV_PARAM_IN; @@ -58,6 +63,13 @@ final class Statement implements StatementInterface */ private array $types = []; + /** + * Resources used as bound values. + * + * @var mixed[]|null + */ + private ?array $paramResources = null; + /** * Append to any INSERT query to retrieve the last insert id. */ @@ -97,6 +109,10 @@ public function bindValue($param, $value, $type = ParameterType::STRING): bool ); } + if ($type === ParameterType::LARGE_OBJECT || $type === ParameterType::BINARY) { + $this->trackParamResource($value); + } + $this->variables[$param] = $value; $this->types[$param] = $type; @@ -159,10 +175,17 @@ public function execute($params = null): ResultInterface } } - $this->stmt ??= $this->prepare(); + $resourceOffsets = $this->getResourceOffsets(); + try { + $this->stmt ??= $this->prepare(); - if (! sqlsrv_execute($this->stmt)) { - throw Error::new(); + if (! sqlsrv_execute($this->stmt)) { + throw Error::new(); + } + } finally { + if ($resourceOffsets !== null) { + $this->restoreResourceOffsets($resourceOffsets); + } } return new Result($this->stmt); @@ -220,4 +243,73 @@ private function prepare() return $stmt; } + + /** + * Track a binary parameter reference at binding time. These + * are cached for later analysis by the getResourceOffsets. + * + * @param mixed $resource + */ + private function trackParamResource($resource): void + { + if (! is_resource($resource)) { + return; + } + + $this->paramResources ??= []; + $this->paramResources[] = $resource; + } + + /** + * Determine the offset that any resource parameters needs to be + * restored to after the statement is executed. Call immediately + * before execute (not during bindValue) to get the most accurate offset. + * + * @return int[]|null Return offsets to restore if needed. The array may be sparse. + */ + private function getResourceOffsets(): ?array + { + if ($this->paramResources === null) { + return null; + } + + $resourceOffsets = null; + foreach ($this->paramResources as $index => $resource) { + $position = false; + if (stream_get_meta_data($resource)['seekable']) { + $position = ftell($resource); + } + + if ($position === false) { + continue; + } + + $resourceOffsets ??= []; + $resourceOffsets[$index] = $position; + } + + if ($resourceOffsets === null) { + $this->paramResources = null; + } + + return $resourceOffsets; + } + + /** + * Restore resource offsets moved by PDOStatement->execute + * + * @param int[]|null $resourceOffsets The offsets returned by getResourceOffsets. + */ + private function restoreResourceOffsets(?array $resourceOffsets): void + { + if ($resourceOffsets === null || $this->paramResources === null) { + return; + } + + foreach ($resourceOffsets as $index => $offset) { + fseek($this->paramResources[$index], $offset, SEEK_SET); + } + + $this->paramResources = null; + } } diff --git a/src/Driver/SQLite3/Statement.php b/src/Driver/SQLite3/Statement.php index a4166aa6137..dab95ad6e84 100644 --- a/src/Driver/SQLite3/Statement.php +++ b/src/Driver/SQLite3/Statement.php @@ -10,9 +10,14 @@ use SQLite3Stmt; use function assert; +use function fseek; +use function ftell; use function func_num_args; use function is_int; +use function is_resource; +use function stream_get_meta_data; +use const SEEK_SET; use const SQLITE3_BLOB; use const SQLITE3_INTEGER; use const SQLITE3_NULL; @@ -33,6 +38,9 @@ final class Statement implements StatementInterface private SQLite3 $connection; private SQLite3Stmt $statement; + /** @var mixed[]|null */ + private ?array $paramResources = null; + /** @internal The statement can be only instantiated by its driver connection. */ public function __construct(SQLite3 $connection, SQLite3Stmt $statement) { @@ -58,7 +66,12 @@ public function bindValue($param, $value, $type = ParameterType::STRING): bool ); } - return $this->statement->bindValue($param, $value, $this->convertParamType($type)); + $sqliteType = $this->convertParamType($type); + if ($sqliteType === SQLITE3_BLOB) { + $this->trackParamResource($value); + } + + return $this->statement->bindValue($param, $value, $sqliteType); } /** @@ -109,10 +122,15 @@ public function execute($params = null): Result } } + $resourceOffsets = $this->getResourceOffsets(); try { $result = $this->statement->execute(); } catch (\Exception $e) { throw Exception::new($e); + } finally { + if ($resourceOffsets !== null) { + $this->restoreResourceOffsets($resourceOffsets); + } } assert($result !== false); @@ -133,4 +151,73 @@ private function convertParamType(int $type): int return self::PARAM_TYPE_MAP[$type]; } + + /** + * Track a binary parameter reference at binding time. These + * are cached for later analysis by the getResourceOffsets. + * + * @param mixed $resource + */ + private function trackParamResource($resource): void + { + if (! is_resource($resource)) { + return; + } + + $this->paramResources ??= []; + $this->paramResources[] = $resource; + } + + /** + * Determine the offset that any resource parameters needs to be + * restored to after the statement is executed. Call immediately + * before execute (not during bindValue) to get the most accurate offset. + * + * @return int[]|null Return offsets to restore if needed. The array may be sparse. + */ + private function getResourceOffsets(): ?array + { + if ($this->paramResources === null) { + return null; + } + + $resourceOffsets = null; + foreach ($this->paramResources as $index => $resource) { + $position = false; + if (stream_get_meta_data($resource)['seekable']) { + $position = ftell($resource); + } + + if ($position === false) { + continue; + } + + $resourceOffsets ??= []; + $resourceOffsets[$index] = $position; + } + + if ($resourceOffsets === null) { + $this->paramResources = null; + } + + return $resourceOffsets; + } + + /** + * Restore resource offsets moved by PDOStatement->execute + * + * @param int[]|null $resourceOffsets The offsets returned by getResourceOffsets. + */ + private function restoreResourceOffsets(?array $resourceOffsets): void + { + if ($resourceOffsets === null || $this->paramResources === null) { + return; + } + + foreach ($resourceOffsets as $index => $offset) { + fseek($this->paramResources[$index], $offset, SEEK_SET); + } + + $this->paramResources = null; + } } diff --git a/tests/Functional/BlobTest.php b/tests/Functional/BlobTest.php index 212f4a4480e..f00fd8bb3e6 100644 --- a/tests/Functional/BlobTest.php +++ b/tests/Functional/BlobTest.php @@ -10,6 +10,9 @@ use Doctrine\DBAL\Types\Types; use function fopen; +use function fseek; +use function ftell; +use function fwrite; use function str_repeat; use function stream_get_contents; @@ -198,6 +201,28 @@ public function testBlobBindingDoesNotOverwritePrevious(): void self::assertEquals(['test1', 'test2'], $actual); } + public function testBindValueResetsStream(): void + { + if (TestUtil::isDriverOneOf('oci8')) { + self::markTestIncomplete('The oci8 driver does not support stream resources as parameters'); + } + + $stmt = $this->connection->prepare( + "INSERT INTO blob_table(id, clobcolumn, blobcolumn) VALUES (1, 'ignored', ?)", + ); + + $stream = fopen('php://temp', 'rb+'); + fwrite($stream, 'a test'); + fseek($stream, 2); + $stmt->bindValue(1, $stream, ParameterType::LARGE_OBJECT); + + $stmt->execute(); + + self::assertEquals(2, ftell($stream), 'Resource parameter should be reset to position before execute.'); + + $this->assertBlobContains('test'); + } + private function assertBlobContains(string $text): void { [, $blobValue] = $this->fetchRow();