Skip to content

Commit 68007b2

Browse files
committed
Restore binary offsets of PDOStatement parameters
Entity identifiers that are PHP resource streams need to work for all references, not just the first one. PDOStatement->execute is reading the binary resources but not restoring the original offsets. No other code is restoring these streams to their original position so that they can be reused. Examples of failures include empty collections on read (the first lazy load collection on an entity populates correctly, the second is empty) and foreign-key violations while persisting changes (the first entity join produces the correct SQL, the second has no data to read and the FK is violated with the missing binary data). Making this change as close as possible to the external code that moves the stream pointer eliminates the need to do this in calling code. Resource offsets are retrieved immediately before execute in case they change between the bindValue and execute calls. The request was originally for the PDO driver but IBMDB2, Mysql, and PgSQL drivers are also covers. Other drivers will likely also need work. No attempt has been made to fix the deprecated bindParam code path. I do not believe this is called by the current Doctrine code, and is regardless much harder to patch because the reference variables can be replaced during execute, so the original resources may no longer be available to restore after the call. A functional test was added for bindValue and a resource with a seekable position. #5895
1 parent 96d5a70 commit 68007b2

File tree

5 files changed

+210
-8
lines changed

5 files changed

+210
-8
lines changed

src/Driver/IBMDB2/Statement.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@
1010
use Doctrine\DBAL\Driver\Statement as StatementInterface;
1111
use Doctrine\DBAL\ParameterType;
1212
use Doctrine\Deprecations\Deprecation;
13+
use Throwable;
1314

1415
use function assert;
1516
use function db2_bind_param;
1617
use function db2_execute;
1718
use function error_get_last;
1819
use function fclose;
20+
use function fseek;
21+
use function ftell;
1922
use function func_num_args;
2023
use function is_int;
2124
use function is_resource;
@@ -28,6 +31,7 @@
2831
use const DB2_LONG;
2932
use const DB2_PARAM_FILE;
3033
use const DB2_PARAM_IN;
34+
use const SEEK_SET;
3135

3236
final class Statement implements StatementInterface
3337
{
@@ -213,8 +217,23 @@ private function createTemporaryFile()
213217
*/
214218
private function copyStreamToStream($source, $target): void
215219
{
220+
$resetTo = false;
221+
try {
222+
if (stream_get_meta_data($source)['seekable']) {
223+
$resetTo = ftell($source);
224+
}
225+
} catch (Throwable $e) {
226+
// Swallow
227+
}
228+
216229
if (@stream_copy_to_stream($source, $target) === false) {
217230
throw CannotCopyStreamToStream::new(error_get_last());
218231
}
232+
233+
if ($resetTo === false) {
234+
return;
235+
}
236+
237+
fseek($source, $resetTo, SEEK_SET);
219238
}
220239
}

src/Driver/Mysqli/Statement.php

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,23 @@
1313
use Doctrine\Deprecations\Deprecation;
1414
use mysqli_sql_exception;
1515
use mysqli_stmt;
16+
use Throwable;
1617

1718
use function array_fill;
1819
use function assert;
1920
use function count;
2021
use function feof;
2122
use function fread;
23+
use function fseek;
24+
use function ftell;
2225
use function func_num_args;
2326
use function get_resource_type;
2427
use function is_int;
2528
use function is_resource;
2629
use function str_repeat;
30+
use function stream_get_meta_data;
31+
32+
use const SEEK_SET;
2733

2834
final class Statement implements StatementInterface
2935
{
@@ -210,15 +216,30 @@ private function bindTypedParameters(): void
210216
private function sendLongData(array $streams): void
211217
{
212218
foreach ($streams as $paramNr => $stream) {
213-
while (! feof($stream)) {
214-
$chunk = fread($stream, 8192);
215-
216-
if ($chunk === false) {
217-
throw FailedReadingStreamOffset::new($paramNr);
219+
$resetTo = false;
220+
try {
221+
if (stream_get_meta_data($stream)['seekable']) {
222+
$resetTo = ftell($stream);
218223
}
224+
} catch (Throwable $e) {
225+
// Swallow
226+
}
227+
228+
try {
229+
while (! feof($stream)) {
230+
$chunk = fread($stream, 8192);
219231

220-
if (! $this->stmt->send_long_data($paramNr - 1, $chunk)) {
221-
throw StatementError::new($this->stmt);
232+
if ($chunk === false) {
233+
throw FailedReadingStreamOffset::new($paramNr);
234+
}
235+
236+
if (! $this->stmt->send_long_data($paramNr - 1, $chunk)) {
237+
throw StatementError::new($this->stmt);
238+
}
239+
}
240+
} finally {
241+
if ($resetTo !== false) {
242+
fseek($stream, $resetTo, SEEK_SET);
222243
}
223244
}
224245
}

src/Driver/PDO/Statement.php

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,30 @@
66
use Doctrine\DBAL\Driver\Statement as StatementInterface;
77
use Doctrine\DBAL\ParameterType;
88
use Doctrine\Deprecations\Deprecation;
9+
use PDO;
910
use PDOException;
1011
use PDOStatement;
12+
use Throwable;
1113

14+
use function array_fill;
1215
use function array_slice;
16+
use function count;
17+
use function fseek;
18+
use function ftell;
1319
use function func_get_args;
1420
use function func_num_args;
21+
use function is_resource;
22+
use function stream_get_meta_data;
23+
24+
use const SEEK_SET;
1525

1626
final class Statement implements StatementInterface
1727
{
1828
private PDOStatement $stmt;
1929

30+
/** @var mixed[]|null */
31+
private ?array $paramResources = null;
32+
2033
/** @internal The statement can be only instantiated by its driver connection. */
2134
public function __construct(PDOStatement $stmt)
2235
{
@@ -38,6 +51,9 @@ public function bindValue($param, $value, $type = ParameterType::STRING)
3851
}
3952

4053
$pdoType = ParameterTypeMap::convertParamType($type);
54+
if ($pdoType === PDO::PARAM_LOB) {
55+
$this->trackParamResource($value);
56+
}
4157

4258
try {
4359
return $this->stmt->bindValue($param, $value, $pdoType);
@@ -117,12 +133,109 @@ public function execute($params = null): ResultInterface
117133
);
118134
}
119135

136+
$resourceOffsets = $this->getResourceOffsets();
120137
try {
121138
$this->stmt->execute($params);
122139
} catch (PDOException $exception) {
123140
throw Exception::new($exception);
141+
} finally {
142+
if ($resourceOffsets !== null) {
143+
$this->restoreResourceOffsets($resourceOffsets);
144+
}
124145
}
125146

126147
return new Result($this->stmt);
127148
}
149+
150+
/**
151+
* Track a binary parameter reference at binding time. These
152+
* are cached for later analysis by the getResourceOffsets.
153+
*
154+
* @param mixed $resource
155+
*/
156+
private function trackParamResource($resource): void
157+
{
158+
if (! is_resource($resource)) {
159+
return;
160+
}
161+
162+
$resources = &$this->paramResources;
163+
$resources ??= [];
164+
$resources[] = $resource;
165+
}
166+
167+
/**
168+
* Determine the offset that any resource parameters needs to be
169+
* restored to after the statement is executed. Call immediately
170+
* before execute (not during bindValue) to get the most accurate offset.
171+
*
172+
* @return int[]|null Return offsets to restore if needed.
173+
*/
174+
private function getResourceOffsets()
175+
{
176+
$resources = &$this->paramResources;
177+
if ($resources === null) {
178+
return null;
179+
}
180+
181+
$count = count($resources);
182+
$resourceOffsets = null;
183+
$deref = [];
184+
for ($i = 0; $i < $count; ++$i) {
185+
$resource = $resources[$i];
186+
$position = false;
187+
try {
188+
if (stream_get_meta_data($resource)['seekable']) {
189+
$position = ftell($resource);
190+
}
191+
} catch (Throwable $e) {
192+
// Swallow
193+
} finally {
194+
if ($position === false) {
195+
if ($resourceOffsets !== null) {
196+
$resourceOffsets[] = -1;
197+
}
198+
} else {
199+
$resourceOffsets ??= $i !== 0 ? array_fill(0, $i, -1) : [];
200+
$resourceOffsets[] = $position;
201+
}
202+
}
203+
}
204+
205+
if ($resourceOffsets === null) {
206+
$resources = null;
207+
}
208+
209+
return $resourceOffsets;
210+
}
211+
212+
/**
213+
* Restore resource offsets moved by PDOStatement->execute
214+
*
215+
* @param int[]|null $resourceOffsets The offsets returned by getResourceOffsets.
216+
*/
217+
private function restoreResourceOffsets($resourceOffsets): void
218+
{
219+
if ($resourceOffsets === null) {
220+
return;
221+
}
222+
223+
$resources = &$this->paramResources;
224+
if ($resources === null) {
225+
return;
226+
}
227+
228+
$count = count($resources);
229+
for ($i = 0; $i < $count; ++$i) {
230+
$offset = $resourceOffsets[$i];
231+
// Check the special value indicating that we can't restore the offset
232+
if ($offset === -1) {
233+
continue;
234+
}
235+
236+
fseek($resources[$i], $offset, SEEK_SET);
237+
}
238+
239+
$resources = null;
240+
}
128241
}

src/Driver/PgSQL/Statement.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
use Doctrine\DBAL\ParameterType;
88
use Doctrine\Deprecations\Deprecation;
99
use PgSql\Connection as PgSqlConnection;
10+
use Throwable;
1011
use TypeError;
1112

1213
use function assert;
14+
use function fseek;
15+
use function ftell;
1316
use function func_num_args;
1417
use function get_class;
1518
use function gettype;
@@ -26,6 +29,9 @@
2629
use function pg_send_execute;
2730
use function sprintf;
2831
use function stream_get_contents;
32+
use function stream_get_meta_data;
33+
34+
use const SEEK_SET;
2935

3036
final class Statement implements StatementInterface
3137
{
@@ -151,10 +157,28 @@ public function execute($params = null): Result
151157
switch ($this->parameterTypes[$parameter]) {
152158
case ParameterType::BINARY:
153159
case ParameterType::LARGE_OBJECT:
160+
$isResource = $value !== null && is_resource($value);
161+
$resource = $value;
162+
$resetTo = false;
163+
if ($isResource) {
164+
try {
165+
if (stream_get_meta_data($resource)['seekable']) {
166+
$resetTo = ftell($resource);
167+
}
168+
} catch (Throwable $e) {
169+
// Swallow
170+
}
171+
}
172+
154173
$escapedParameters[] = $value === null ? null : pg_escape_bytea(
155174
$this->connection,
156-
is_resource($value) ? stream_get_contents($value) : $value,
175+
$isResource ? stream_get_contents($value) : $value,
157176
);
177+
178+
if ($resetTo !== false) {
179+
fseek($resource, $resetTo, SEEK_SET);
180+
}
181+
158182
break;
159183
default:
160184
$escapedParameters[] = $value;

tests/Functional/BlobTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
use Doctrine\DBAL\Types\Type;
1010

1111
use function fopen;
12+
use function fseek;
13+
use function ftell;
14+
use function fwrite;
1215
use function str_repeat;
1316
use function stream_get_contents;
1417

@@ -197,6 +200,28 @@ public function testBlobBindingDoesNotOverwritePrevious(): void
197200
self::assertEquals(['test1', 'test2'], $actual);
198201
}
199202

203+
public function testBindValueResetsStream(): void
204+
{
205+
if (TestUtil::isDriverOneOf('oci8')) {
206+
self::markTestIncomplete('The oci8 driver does not support stream resources as parameters');
207+
}
208+
209+
$stmt = $this->connection->prepare(
210+
"INSERT INTO blob_table(id, clobcolumn, blobcolumn) VALUES (1, 'ignored', ?)",
211+
);
212+
213+
$stream = fopen('php://temp', 'rb+');
214+
fwrite($stream, 'a test');
215+
fseek($stream, 2);
216+
$stmt->bindValue(1, $stream, ParameterType::LARGE_OBJECT);
217+
218+
$stmt->execute();
219+
220+
self::assertEquals(2, ftell($stream), 'Resource parameter should be reset to position before execute.');
221+
222+
$this->assertBlobContains('test');
223+
}
224+
200225
private function assertBlobContains(string $text): void
201226
{
202227
[, $blobValue] = $this->fetchRow();

0 commit comments

Comments
 (0)