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
This commit is contained in:
Michal Sniatala 2024-12-27 08:14:06 +01:00 committed by GitHub
parent cc1b8f2856
commit 6cbbf601b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 124 additions and 17 deletions

View File

@ -26,6 +26,7 @@ $finder = Finder::create()
'_support/View/Cells/multiplier.php',
'_support/View/Cells/colors.php',
'_support/View/Cells/addition.php',
'system/Database/Live/PreparedQueryTest.php',
])
->notName('#Foobar.php$#');

View File

@ -259,4 +259,12 @@ abstract class BasePreparedQuery implements PreparedQueryInterface
{
return $this->errorString;
}
/**
* Whether the input contain binary data.
*/
protected function isBinary(string $input): bool
{
return mb_detect_encoding($input, 'UTF-8', true) === false;
}
}

View File

@ -66,15 +66,19 @@ class PreparedQuery extends BasePreparedQuery
throw new BadMethodCallException('You must call prepare before trying to execute a prepared statement.');
}
// First off -bind the parameters
$bindTypes = '';
// First off - bind the parameters
$bindTypes = '';
$binaryData = [];
// Determine the type string
foreach ($data as $item) {
foreach ($data as $key => $item) {
if (is_int($item)) {
$bindTypes .= 'i';
} elseif (is_numeric($item)) {
$bindTypes .= 'd';
} elseif (is_string($item) && $this->isBinary($item)) {
$bindTypes .= 'b';
$binaryData[$key] = $item;
} else {
$bindTypes .= 's';
}
@ -83,6 +87,11 @@ class PreparedQuery extends BasePreparedQuery
// Bind it
$this->statement->bind_param($bindTypes, ...$data);
// Stream binary data
foreach ($binaryData as $key => $value) {
$this->statement->send_long_data($key, $value);
}
try {
return $this->statement->execute();
} catch (mysqli_sql_exception $e) {

View File

@ -16,6 +16,7 @@ namespace CodeIgniter\Database\OCI8;
use BadMethodCallException;
use CodeIgniter\Database\BasePreparedQuery;
use CodeIgniter\Database\Exceptions\DatabaseException;
use OCILob;
/**
* Prepared query for OCI8
@ -73,12 +74,24 @@ class PreparedQuery extends BasePreparedQuery
throw new BadMethodCallException('You must call prepare before trying to execute a prepared statement.');
}
$binaryData = null;
foreach (array_keys($data) as $key) {
oci_bind_by_name($this->statement, ':' . $key, $data[$key]);
if (is_string($data[$key]) && $this->isBinary($data[$key])) {
$binaryData = oci_new_descriptor($this->db->connID, OCI_D_LOB);
$binaryData->writeTemporary($data[$key], OCI_TEMP_BLOB);
oci_bind_by_name($this->statement, ':' . $key, $binaryData, -1, OCI_B_BLOB);
} else {
oci_bind_by_name($this->statement, ':' . $key, $data[$key]);
}
}
$result = oci_execute($this->statement, $this->db->commitMode);
if ($binaryData instanceof OCILob) {
$binaryData->free();
}
if ($result && $this->lastInsertTableName !== '') {
$this->db->lastInsertedTableName = $this->lastInsertTableName;
}

View File

@ -173,6 +173,10 @@ class Forge extends BaseForge
$attributes['TYPE'] = 'TIMESTAMP';
break;
case 'BLOB':
$attributes['TYPE'] = 'BYTEA';
break;
default:
break;
}

View File

@ -87,6 +87,12 @@ class PreparedQuery extends BasePreparedQuery
throw new BadMethodCallException('You must call prepare before trying to execute a prepared statement.');
}
foreach ($data as &$item) {
if (is_string($item) && $this->isBinary($item)) {
$item = pg_escape_bytea($this->db->connID, $item);
}
}
$this->result = pg_execute($this->db->connID, $this->name, $data);
return (bool) $this->result;

View File

@ -368,7 +368,11 @@ class Connection extends BaseConnection
$retVal[$i]->max_length = $query[$i]->CHARACTER_MAXIMUM_LENGTH > 0
? $query[$i]->CHARACTER_MAXIMUM_LENGTH
: $query[$i]->NUMERIC_PRECISION;
: (
$query[$i]->CHARACTER_MAXIMUM_LENGTH === -1
? 'max'
: $query[$i]->NUMERIC_PRECISION
);
$retVal[$i]->nullable = $query[$i]->IS_NULLABLE !== 'NO';
$retVal[$i]->default = $query[$i]->COLUMN_DEFAULT;

View File

@ -397,6 +397,11 @@ class Forge extends BaseForge
$attributes['TYPE'] = 'BIT';
break;
case 'BLOB':
$attributes['TYPE'] = 'VARBINARY';
$attributes['CONSTRAINT'] ??= 'MAX';
break;
default:
break;
}

View File

@ -59,7 +59,7 @@ class PreparedQuery extends BasePreparedQuery
// Prepare parameters for the query
$queryString = $this->getQueryString();
$parameters = $this->parameterize($queryString);
$parameters = $this->parameterize($queryString, $options);
// Prepare the query
$this->statement = sqlsrv_prepare($this->db->connID, $sql, $parameters);
@ -120,8 +120,10 @@ class PreparedQuery extends BasePreparedQuery
/**
* Handle parameters.
*
* @param array<int, mixed> $options
*/
protected function parameterize(string $queryString): array
protected function parameterize(string $queryString, array $options): array
{
$numberOfVariables = substr_count($queryString, '?');
@ -129,7 +131,11 @@ class PreparedQuery extends BasePreparedQuery
for ($c = 0; $c < $numberOfVariables; $c++) {
$this->parameters[$c] = null;
$params[] = &$this->parameters[$c];
if (isset($options[$c])) {
$params[] = [&$this->parameters[$c], SQLSRV_PARAM_IN, $options[$c]];
} else {
$params[] = &$this->parameters[$c];
}
}
return $params;

View File

@ -75,6 +75,8 @@ class PreparedQuery extends BasePreparedQuery
$bindType = SQLITE3_INTEGER;
} elseif (is_float($item)) {
$bindType = SQLITE3_FLOAT;
} elseif (is_string($item) && $this->isBinary($item)) {
$bindType = SQLITE3_BLOB;
} else {
$bindType = SQLITE3_TEXT;
}

View File

@ -99,8 +99,7 @@ class Migration_Create_test_tables extends Migration
unset(
$dataTypeFields['type_set'],
$dataTypeFields['type_mediumtext'],
$dataTypeFields['type_double'],
$dataTypeFields['type_blob']
$dataTypeFields['type_double']
);
}

View File

@ -104,8 +104,8 @@ abstract class AbstractGetFieldDataTestCase extends CIUnitTestCase
$this->forge->dropTable($this->table, true);
// missing types:
// TINYINT,MEDIUMINT,BIT,YEAR,BINARY,VARBINARY,TINYTEXT,LONGTEXT,
// JSON,Spatial data types
// TINYINT,MEDIUMINT,BIT,YEAR,BINARY,VARBINARY (BLOB more or less handles these two),
// TINYTEXT,LONGTEXT,JSON,Spatial data types
// `id` must be INTEGER else SQLite3 error on not null for autoincrement field.
$fields = [
'id' => ['type' => 'INTEGER', 'constraint' => 20, 'auto_increment' => true],
@ -138,8 +138,7 @@ abstract class AbstractGetFieldDataTestCase extends CIUnitTestCase
$fields['type_enum'],
$fields['type_set'],
$fields['type_mediumtext'],
$fields['type_double'],
$fields['type_blob']
$fields['type_double']
);
}
@ -147,8 +146,7 @@ abstract class AbstractGetFieldDataTestCase extends CIUnitTestCase
unset(
$fields['type_set'],
$fields['type_mediumtext'],
$fields['type_double'],
$fields['type_blob']
$fields['type_double']
);
}

View File

@ -212,6 +212,13 @@ final class GetFieldDataTestCase extends AbstractGetFieldDataTestCase
'default' => null,
],
15 => (object) [
'name' => 'type_blob',
'type' => 'bytea',
'max_length' => null,
'nullable' => true,
'default' => null,
],
16 => (object) [
'name' => 'type_boolean',
'type' => 'boolean',
'max_length' => null,

View File

@ -269,4 +269,39 @@ final class PreparedQueryTest extends CIUnitTestCase
$this->query->close();
}
public function testInsertBinaryData(): void
{
$params = [];
if ($this->db->DBDriver === 'SQLSRV') {
$params = [0 => SQLSRV_PHPTYPE_STREAM(SQLSRV_ENC_BINARY)];
}
$this->query = $this->db->prepare(static fn ($db) => $db->table('type_test')->insert([
'type_blob' => 'binary',
]), $params);
$fileContent = file_get_contents(TESTPATH . '_support/Images/EXIFsamples/landscape_0.jpg');
$this->assertTrue($this->query->execute($fileContent));
$id = $this->db->DBDriver === 'SQLSRV'
// It seems like INSERT for a prepared statement is run in the
// separate execution context even though it's part of the same session
? (int) ($this->db->query('SELECT @@IDENTITY AS insert_id')->getRow()->insert_id ?? 0)
: $this->db->insertID();
$builder = $this->db->table('type_test');
if ($this->db->DBDriver === 'Postgre') {
$file = $builder->select("ENCODE(type_blob, 'base64') AS type_blob")->where('id', $id)->get()->getRow();
$file = base64_decode($file->type_blob, true);
} elseif ($this->db->DBDriver === 'OCI8') {
$file = $builder->select('type_blob')->where('id', $id)->get()->getRow();
$file = $file->type_blob->load();
} else {
$file = $builder->select('type_blob')->where('id', $id)->get()->getRow();
$file = $file->type_blob;
}
$this->assertSame(strlen($fileContent), strlen($file));
}
}

View File

@ -219,6 +219,13 @@ final class GetFieldDataTestCase extends AbstractGetFieldDataTestCase
'default' => null,
],
16 => (object) [
'name' => 'type_blob',
'type' => 'varbinary',
'max_length' => 'max',
'nullable' => true,
'default' => null,
],
17 => (object) [
'name' => 'type_boolean',
'type' => 'bit',
'max_length' => null,

View File

@ -41,7 +41,8 @@ Bugs Fixed
- **Validation:** Fixed a bug where complex language strings were not properly handled.
- **CURLRequest:** Added support for handling proxy responses using HTTP versions other than 1.1.
- **Database:** Fixed a bug that caused ``Postgre\Connection::reconnect()`` method to throw an error when the connection had not yet been established.
- **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.
- **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.
- **Database:** Fixed a bug in prepared statement to correctly handle binary data.
See the repo's
`CHANGELOG.md <https://github.com/codeigniter4/CodeIgniter4/blob/develop/CHANGELOG.md>`_

View File

@ -246,6 +246,8 @@ array through in the second parameter:
.. literalinclude:: queries/018.php
.. note:: Currently, the only database that actually uses the array of option is SQLSRV.
Executing the Query
===================