Skip to content

Commit 6cbbf60

Browse files
authored
fix: handling binary data for prepared statement (#9337)
* fix prepare statement sqlite * fix prepare statement postgre * fix prepare statement sqlsrv * fix prepare statement oci8 * tests * abstract isBinary() method * fix prepare statement mysqli * fix prepare statement oci8 * sqlsrv blob support * fix tests * add changelog * fix rector * make sqlsrv happy * make oci8 happy - hopefully * add a note about options for prepared statement * ignore PreparedQueryTest.php file * apply code suggestion for oci8
1 parent cc1b8f2 commit 6cbbf60

17 files changed

+124
-17
lines changed

.php-cs-fixer.tests.php

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
'_support/View/Cells/multiplier.php',
2727
'_support/View/Cells/colors.php',
2828
'_support/View/Cells/addition.php',
29+
'system/Database/Live/PreparedQueryTest.php',
2930
])
3031
->notName('#Foobar.php$#');
3132

system/Database/BasePreparedQuery.php

+8
Original file line numberDiff line numberDiff line change
@@ -259,4 +259,12 @@ public function getErrorMessage(): string
259259
{
260260
return $this->errorString;
261261
}
262+
263+
/**
264+
* Whether the input contain binary data.
265+
*/
266+
protected function isBinary(string $input): bool
267+
{
268+
return mb_detect_encoding($input, 'UTF-8', true) === false;
269+
}
262270
}

system/Database/MySQLi/PreparedQuery.php

+12-3
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,19 @@ public function _execute(array $data): bool
6666
throw new BadMethodCallException('You must call prepare before trying to execute a prepared statement.');
6767
}
6868

69-
// First off -bind the parameters
70-
$bindTypes = '';
69+
// First off - bind the parameters
70+
$bindTypes = '';
71+
$binaryData = [];
7172

7273
// Determine the type string
73-
foreach ($data as $item) {
74+
foreach ($data as $key => $item) {
7475
if (is_int($item)) {
7576
$bindTypes .= 'i';
7677
} elseif (is_numeric($item)) {
7778
$bindTypes .= 'd';
79+
} elseif (is_string($item) && $this->isBinary($item)) {
80+
$bindTypes .= 'b';
81+
$binaryData[$key] = $item;
7882
} else {
7983
$bindTypes .= 's';
8084
}
@@ -83,6 +87,11 @@ public function _execute(array $data): bool
8387
// Bind it
8488
$this->statement->bind_param($bindTypes, ...$data);
8589

90+
// Stream binary data
91+
foreach ($binaryData as $key => $value) {
92+
$this->statement->send_long_data($key, $value);
93+
}
94+
8695
try {
8796
return $this->statement->execute();
8897
} catch (mysqli_sql_exception $e) {

system/Database/OCI8/PreparedQuery.php

+14-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use BadMethodCallException;
1717
use CodeIgniter\Database\BasePreparedQuery;
1818
use CodeIgniter\Database\Exceptions\DatabaseException;
19+
use OCILob;
1920

2021
/**
2122
* Prepared query for OCI8
@@ -73,12 +74,24 @@ public function _execute(array $data): bool
7374
throw new BadMethodCallException('You must call prepare before trying to execute a prepared statement.');
7475
}
7576

77+
$binaryData = null;
78+
7679
foreach (array_keys($data) as $key) {
77-
oci_bind_by_name($this->statement, ':' . $key, $data[$key]);
80+
if (is_string($data[$key]) && $this->isBinary($data[$key])) {
81+
$binaryData = oci_new_descriptor($this->db->connID, OCI_D_LOB);
82+
$binaryData->writeTemporary($data[$key], OCI_TEMP_BLOB);
83+
oci_bind_by_name($this->statement, ':' . $key, $binaryData, -1, OCI_B_BLOB);
84+
} else {
85+
oci_bind_by_name($this->statement, ':' . $key, $data[$key]);
86+
}
7887
}
7988

8089
$result = oci_execute($this->statement, $this->db->commitMode);
8190

91+
if ($binaryData instanceof OCILob) {
92+
$binaryData->free();
93+
}
94+
8295
if ($result && $this->lastInsertTableName !== '') {
8396
$this->db->lastInsertedTableName = $this->lastInsertTableName;
8497
}

system/Database/Postgre/Forge.php

+4
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ protected function _attributeType(array &$attributes)
173173
$attributes['TYPE'] = 'TIMESTAMP';
174174
break;
175175

176+
case 'BLOB':
177+
$attributes['TYPE'] = 'BYTEA';
178+
break;
179+
176180
default:
177181
break;
178182
}

system/Database/Postgre/PreparedQuery.php

+6
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ public function _execute(array $data): bool
8787
throw new BadMethodCallException('You must call prepare before trying to execute a prepared statement.');
8888
}
8989

90+
foreach ($data as &$item) {
91+
if (is_string($item) && $this->isBinary($item)) {
92+
$item = pg_escape_bytea($this->db->connID, $item);
93+
}
94+
}
95+
9096
$this->result = pg_execute($this->db->connID, $this->name, $data);
9197

9298
return (bool) $this->result;

system/Database/SQLSRV/Connection.php

+5-1
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,11 @@ protected function _fieldData(string $table): array
368368

369369
$retVal[$i]->max_length = $query[$i]->CHARACTER_MAXIMUM_LENGTH > 0
370370
? $query[$i]->CHARACTER_MAXIMUM_LENGTH
371-
: $query[$i]->NUMERIC_PRECISION;
371+
: (
372+
$query[$i]->CHARACTER_MAXIMUM_LENGTH === -1
373+
? 'max'
374+
: $query[$i]->NUMERIC_PRECISION
375+
);
372376

373377
$retVal[$i]->nullable = $query[$i]->IS_NULLABLE !== 'NO';
374378
$retVal[$i]->default = $query[$i]->COLUMN_DEFAULT;

system/Database/SQLSRV/Forge.php

+5
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,11 @@ protected function _attributeType(array &$attributes)
397397
$attributes['TYPE'] = 'BIT';
398398
break;
399399

400+
case 'BLOB':
401+
$attributes['TYPE'] = 'VARBINARY';
402+
$attributes['CONSTRAINT'] ??= 'MAX';
403+
break;
404+
400405
default:
401406
break;
402407
}

system/Database/SQLSRV/PreparedQuery.php

+9-3
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public function _prepare(string $sql, array $options = []): PreparedQuery
5959
// Prepare parameters for the query
6060
$queryString = $this->getQueryString();
6161

62-
$parameters = $this->parameterize($queryString);
62+
$parameters = $this->parameterize($queryString, $options);
6363

6464
// Prepare the query
6565
$this->statement = sqlsrv_prepare($this->db->connID, $sql, $parameters);
@@ -120,16 +120,22 @@ protected function _close(): bool
120120

121121
/**
122122
* Handle parameters.
123+
*
124+
* @param array<int, mixed> $options
123125
*/
124-
protected function parameterize(string $queryString): array
126+
protected function parameterize(string $queryString, array $options): array
125127
{
126128
$numberOfVariables = substr_count($queryString, '?');
127129

128130
$params = [];
129131

130132
for ($c = 0; $c < $numberOfVariables; $c++) {
131133
$this->parameters[$c] = null;
132-
$params[] = &$this->parameters[$c];
134+
if (isset($options[$c])) {
135+
$params[] = [&$this->parameters[$c], SQLSRV_PARAM_IN, $options[$c]];
136+
} else {
137+
$params[] = &$this->parameters[$c];
138+
}
133139
}
134140

135141
return $params;

system/Database/SQLite3/PreparedQuery.php

+2
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ public function _execute(array $data): bool
7575
$bindType = SQLITE3_INTEGER;
7676
} elseif (is_float($item)) {
7777
$bindType = SQLITE3_FLOAT;
78+
} elseif (is_string($item) && $this->isBinary($item)) {
79+
$bindType = SQLITE3_BLOB;
7880
} else {
7981
$bindType = SQLITE3_TEXT;
8082
}

tests/_support/Database/Migrations/20160428212500_Create_test_tables.php

+1-2
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,7 @@ public function up(): void
9999
unset(
100100
$dataTypeFields['type_set'],
101101
$dataTypeFields['type_mediumtext'],
102-
$dataTypeFields['type_double'],
103-
$dataTypeFields['type_blob']
102+
$dataTypeFields['type_double']
104103
);
105104
}
106105

tests/system/Database/Live/AbstractGetFieldDataTestCase.php

+4-6
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ protected function createTableForType(): void
104104
$this->forge->dropTable($this->table, true);
105105

106106
// missing types:
107-
// TINYINT,MEDIUMINT,BIT,YEAR,BINARY,VARBINARY,TINYTEXT,LONGTEXT,
108-
// JSON,Spatial data types
107+
// TINYINT,MEDIUMINT,BIT,YEAR,BINARY,VARBINARY (BLOB more or less handles these two),
108+
// TINYTEXT,LONGTEXT,JSON,Spatial data types
109109
// `id` must be INTEGER else SQLite3 error on not null for autoincrement field.
110110
$fields = [
111111
'id' => ['type' => 'INTEGER', 'constraint' => 20, 'auto_increment' => true],
@@ -138,17 +138,15 @@ protected function createTableForType(): void
138138
$fields['type_enum'],
139139
$fields['type_set'],
140140
$fields['type_mediumtext'],
141-
$fields['type_double'],
142-
$fields['type_blob']
141+
$fields['type_double']
143142
);
144143
}
145144

146145
if ($this->db->DBDriver === 'SQLSRV') {
147146
unset(
148147
$fields['type_set'],
149148
$fields['type_mediumtext'],
150-
$fields['type_double'],
151-
$fields['type_blob']
149+
$fields['type_double']
152150
);
153151
}
154152

tests/system/Database/Live/Postgre/GetFieldDataTestCase.php

+7
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,13 @@ public function testGetFieldDataType(): void
212212
'default' => null,
213213
],
214214
15 => (object) [
215+
'name' => 'type_blob',
216+
'type' => 'bytea',
217+
'max_length' => null,
218+
'nullable' => true,
219+
'default' => null,
220+
],
221+
16 => (object) [
215222
'name' => 'type_boolean',
216223
'type' => 'boolean',
217224
'max_length' => null,

tests/system/Database/Live/PreparedQueryTest.php

+35
Original file line numberDiff line numberDiff line change
@@ -269,4 +269,39 @@ public function testDeallocatePreparedQueryThenTryToClose(): void
269269

270270
$this->query->close();
271271
}
272+
273+
public function testInsertBinaryData(): void
274+
{
275+
$params = [];
276+
if ($this->db->DBDriver === 'SQLSRV') {
277+
$params = [0 => SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY)];
278+
}
279+
280+
$this->query = $this->db->prepare(static fn ($db) => $db->table('type_test')->insert([
281+
'type_blob' => 'binary',
282+
]), $params);
283+
284+
$fileContent = file_get_contents(TESTPATH . '_support/Images/EXIFsamples/landscape_0.jpg');
285+
$this->assertTrue($this->query->execute($fileContent));
286+
287+
$id = $this->db->DBDriver === 'SQLSRV'
288+
// It seems like INSERT for a prepared statement is run in the
289+
// separate execution context even though it's part of the same session
290+
? (int) ($this->db->query('SELECT @@IDENTITY AS insert_id')->getRow()->insert_id ?? 0)
291+
: $this->db->insertID();
292+
$builder = $this->db->table('type_test');
293+
294+
if ($this->db->DBDriver === 'Postgre') {
295+
$file = $builder->select("ENCODE(type_blob, 'base64') AS type_blob")->where('id', $id)->get()->getRow();
296+
$file = base64_decode($file->type_blob, true);
297+
} elseif ($this->db->DBDriver === 'OCI8') {
298+
$file = $builder->select('type_blob')->where('id', $id)->get()->getRow();
299+
$file = $file->type_blob->load();
300+
} else {
301+
$file = $builder->select('type_blob')->where('id', $id)->get()->getRow();
302+
$file = $file->type_blob;
303+
}
304+
305+
$this->assertSame(strlen($fileContent), strlen($file));
306+
}
272307
}

tests/system/Database/Live/SQLSRV/GetFieldDataTestCase.php

+7
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,13 @@ public function testGetFieldDataType(): void
219219
'default' => null,
220220
],
221221
16 => (object) [
222+
'name' => 'type_blob',
223+
'type' => 'varbinary',
224+
'max_length' => 'max',
225+
'nullable' => true,
226+
'default' => null,
227+
],
228+
17 => (object) [
222229
'name' => 'type_boolean',
223230
'type' => 'bit',
224231
'max_length' => null,

user_guide_src/source/changelogs/v4.5.6.rst

+2-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ Bugs Fixed
4141
- **Validation:** Fixed a bug where complex language strings were not properly handled.
4242
- **CURLRequest:** Added support for handling proxy responses using HTTP versions other than 1.1.
4343
- **Database:** Fixed a bug that caused ``Postgre\Connection::reconnect()`` method to throw an error when the connection had not yet been established.
44-
- **Model** Fixed a bug that caused the ``Model::getIdValue()`` method to not correctly recognize the primary key in the ``Entity`` object if a data mapping for the primary key was used.
44+
- **Model:** Fixed a bug that caused the ``Model::getIdValue()`` method to not correctly recognize the primary key in the ``Entity`` object if a data mapping for the primary key was used.
45+
- **Database:** Fixed a bug in prepared statement to correctly handle binary data.
4546

4647
See the repo's
4748
`CHANGELOG.md <https://github.com/codeigniter4/CodeIgniter4/blob/develop/CHANGELOG.md>`_

user_guide_src/source/database/queries.rst

+2
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ array through in the second parameter:
246246

247247
.. literalinclude:: queries/018.php
248248

249+
.. note:: Currently, the only database that actually uses the array of option is SQLSRV.
250+
249251
Executing the Query
250252
===================
251253

0 commit comments

Comments
 (0)