Skip to content

Commit bc86edf

Browse files
committed
fix: cast OCI8 batch subquery values so updateBatch() and deleteBatch() accept mixed PHP types
1 parent 483c57f commit bc86edf

4 files changed

Lines changed: 100 additions & 27 deletions

File tree

‎system/Database/OCI8/Builder.php‎

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use CodeIgniter\Database\BaseBuilder;
1717
use CodeIgniter\Database\Exceptions\DatabaseException;
1818
use CodeIgniter\Database\RawSql;
19+
use stdClass;
1920

2021
/**
2122
* Builder for OCI8
@@ -282,14 +283,21 @@ protected function _updateBatch(string $table, array $keys, array $values): stri
282283
if (isset($this->QBOptions['setQueryAsData'])) {
283284
$data = $this->QBOptions['setQueryAsData'];
284285
} else {
286+
$updateFields = $this->QBOptions['updateFields'] ?? [];
287+
$lengths = $this->longestValues($keys, $values);
288+
285289
$data = implode(
286290
" UNION ALL\n",
287291
array_map(
288-
static fn ($value): string => 'SELECT ' . implode(', ', array_map(
289-
static fn ($key, $index): string => $index . ' ' . $key,
292+
fn (array $value): string => sprintf('SELECT %s FROM DUAL', implode(', ', array_map(
293+
fn (string $key, float|int|string $index): string => sprintf(
294+
'%s %s',
295+
$this->cast((string) $index, $this->getSourceField($table, $key, $updateFields), $lengths[$key]),
296+
$key,
297+
),
290298
$keys,
291299
$value,
292-
)) . ' FROM DUAL',
300+
))),
293301
$values,
294302
),
295303
) . "\n";
@@ -466,14 +474,21 @@ protected function _deleteBatch(string $table, array $keys, array $values): stri
466474
if (isset($this->QBOptions['setQueryAsData'])) {
467475
$data = $this->QBOptions['setQueryAsData'];
468476
} else {
477+
$constraints = $this->QBOptions['constraints'] ?? [];
478+
$lengths = $this->longestValues($keys, $values);
479+
469480
$data = implode(
470481
" FROM DUAL UNION ALL\n",
471482
array_map(
472-
static fn ($value): string => 'SELECT ' . implode(', ', array_map(
473-
static fn ($key, $index): string => $index . ' ' . $key,
483+
fn (array $value): string => sprintf('SELECT %s', implode(', ', array_map(
484+
fn (string $key, float|int|string $index): string => sprintf(
485+
'%s %s',
486+
$this->cast((string) $index, $this->getSourceField($table, $key, $constraints), $lengths[$key]),
487+
$key,
488+
),
474489
$keys,
475490
$value,
476-
)),
491+
))),
477492
$values,
478493
),
479494
) . " FROM DUAL\n";
@@ -489,4 +504,82 @@ protected function fieldsFromQuery(string $sql): array
489504
{
490505
return $this->db->query('SELECT * FROM (' . $sql . ') "_u_" WHERE ROWNUM = 1')->getFieldNames();
491506
}
507+
508+
/**
509+
* Returns the longest escaped value per key across the batch.
510+
*
511+
* @param list<string> $keys
512+
* @param list<list<float|int|string>> $values
513+
*
514+
* @return array<string, int>
515+
*/
516+
private function longestValues(array $keys, array $values): array
517+
{
518+
$lengths = [];
519+
520+
foreach ($keys as $i => $key) {
521+
$lengths[$key] = max(array_map(static fn (array $row): int => mb_strlen((string) $row[$i]), $values));
522+
}
523+
524+
return $lengths;
525+
}
526+
527+
/**
528+
* Returns the field shared by every destination column fed from the source key, or null when their types differ.
529+
*
530+
* @param array<array-key, RawSql|string> $map Destination column to source key.
531+
*/
532+
private function getSourceField(string $table, string $key, array $map): ?stdClass
533+
{
534+
$columns = array_filter(array_keys($map, $key, true), is_string(...));
535+
536+
if ($columns === []) {
537+
return $this->getField($table, $key);
538+
}
539+
540+
$fields = array_map(fn (string $column): ?stdClass => $this->getField($table, $column), $columns);
541+
$types = array_unique(array_map(static fn (?stdClass $field): string => $field instanceof stdClass ? $field->type . '(' . $field->max_length . ')' : '', $fields));
542+
543+
return count($types) === 1 ? reset($fields) : null;
544+
}
545+
546+
/**
547+
* Returns the literal converted to the field type, or unchanged when the field is unknown or a LOB.
548+
*
549+
* @param int $length Longest value for the key in the batch.
550+
*/
551+
private function cast(string $value, ?stdClass $field, int $length): string
552+
{
553+
if (! $field instanceof stdClass) {
554+
return $value;
555+
}
556+
557+
return match ($field->type) {
558+
// Every row gets the same fixed width, wide enough for the longest value, so the UNION ALL stays CHAR and nothing is truncated.
559+
'CHAR', 'NCHAR' => sprintf('CAST(%s AS %s(%d))', $value, $field->type, max((int) $field->max_length, $length)),
560+
'VARCHAR2' => sprintf('TO_CHAR(%s)', $value),
561+
'NVARCHAR2' => sprintf('TO_NCHAR(%s)', $value),
562+
'RAW', 'LONG RAW', 'CLOB', 'NCLOB', 'BLOB', 'BFILE', 'LONG' => $value,
563+
default => sprintf('CAST(%s AS %s)', $value, $field->type),
564+
};
565+
}
566+
567+
/**
568+
* @param string $table Protected table name.
569+
* @param string $fieldName Field name. May be protected.
570+
*/
571+
private function getField(string $table, string $fieldName): ?stdClass
572+
{
573+
$fieldName = trim($fieldName, $this->db->escapeChar);
574+
575+
if (! isset($this->QBOptions['fields'][$table])) {
576+
$this->QBOptions['fields'][$table] = [];
577+
578+
foreach ($this->db->getFieldData($table) as $field) {
579+
$this->QBOptions['fields'][$table][$field->name] = $field;
580+
}
581+
}
582+
583+
return $this->QBOptions['fields'][$table][$fieldName] ?? null;
584+
}
492585
}

‎tests/system/Database/Live/DeleteTest.php‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,6 @@ public function testDeleteBatchWithMixedConstraintValueTypesInTextColumn(): void
125125
$this->markTestSkipped('SQL Server cannot compare `text` columns with `=`.');
126126
}
127127

128-
if ($this->db->DBDriver === 'OCI8') {
129-
$this->markTestSkipped('TODO: the OCI8 builder does not cast mixed `UNION ALL` values yet. Remove this skip once it does.');
130-
}
131-
132128
$table = 'type_test';
133129

134130
$builder = $this->db->table($table);
@@ -161,10 +157,6 @@ public function testDeleteBatchWithMixedConstraintValueTypesInTextColumn(): void
161157

162158
public function testDeleteBatchDoesNotTruncateConstraintValueForCharColumn(): void
163159
{
164-
if ($this->db->DBDriver === 'OCI8') {
165-
$this->markTestSkipped('TODO: Oracle resolves a `UNION ALL` of `CHAR` literals with different lengths to `VARCHAR2`, so the OCI8 builder must cast them. Remove this skip once it does.');
166-
}
167-
168160
$table = 'type_test';
169161

170162
$builder = $this->db->table($table);

‎tests/system/Database/Live/UpdateTest.php‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -284,10 +284,6 @@ public function testUpdateBatchWithMixedValueTypesInTextColumn(): void
284284
$this->markTestSkipped('SQL Server cannot compare `text` columns with `=`.');
285285
}
286286

287-
if ($this->db->DBDriver === 'OCI8') {
288-
$this->markTestSkipped('TODO: the OCI8 builder does not cast mixed `UNION ALL` values yet. Remove this skip once it does.');
289-
}
290-
291287
$table = 'type_test';
292288

293289
$builder = $this->db->table($table);
@@ -319,10 +315,6 @@ public function testUpdateBatchWithMixedValueTypesInTextColumn(): void
319315

320316
public function testUpdateBatchDoesNotTruncateConstraintValueForCharColumn(): void
321317
{
322-
if ($this->db->DBDriver === 'OCI8') {
323-
$this->markTestSkipped('TODO: Oracle resolves a `UNION ALL` of `CHAR` literals with different lengths to `VARCHAR2`, so the OCI8 builder must cast them. Remove this skip once it does.');
324-
}
325-
326318
$table = 'type_test';
327319

328320
$builder = $this->db->table($table);
@@ -358,10 +350,6 @@ public function testUpdateBatchWithMappedUpdateFieldsAndMixedValueTypes(): void
358350
$this->markTestSkipped('SQL Server cannot compare `text` columns with `=`.');
359351
}
360352

361-
if ($this->db->DBDriver === 'OCI8') {
362-
$this->markTestSkipped('TODO: the OCI8 builder does not cast mixed `UNION ALL` values yet. Remove this skip once it does.');
363-
}
364-
365353
$table = 'type_test';
366354

367355
$builder = $this->db->table($table);

‎user_guide_src/source/changelogs/v4.7.5.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Bugs Fixed
5555
- **Database:** Fixed a bug where rebuilding a SQLite3 table (e.g., ``Forge::dropColumn()``, ``Forge::modifyColumn()``, ``Forge::dropForeignKey()`` and ``Forge::dropPrimaryKey()``) corrupted the table names referenced by its foreign keys when ``DBPrefix`` was set.
5656
- **Database:** Fixed a bug where Postgre query failures were silently ignored when ``DBDebug`` was enabled and PHP warnings were disabled. A ``DatabaseException`` is now thrown.
5757
- **Database:** Fixed a bug where ``getFieldData()`` failed for a schema-qualified table name on Postgre, SQLSRV and SQLite3, and where ``protectIdentifiers()`` prefixed an already protected segment of a dotted identifier a second time.
58-
- **Database:** Fixed a bug where ``updateBatch()`` and ``deleteBatch()`` failed on PostgreSQL when the rows supplied different PHP types (e.g., a string and an integer) for the same column, because the ``UNION ALL`` subquery values were not cast to the column type.
58+
- **Database:** Fixed a bug where ``updateBatch()`` and ``deleteBatch()`` failed on PostgreSQL and Oracle when the rows supplied different PHP types (e.g., a string and an integer) for the same column, because the ``UNION ALL`` subquery values were not cast to the column type.
5959
- **Debug:** Fixed a bug where ``Timer::start()`` treated ``0.0`` as an empty value and substituted the current time.
6060
- **Files:** Fixed a bug where ``File::move()`` and ``UploadedFile::move()`` set executable and overly permissive file permissions (``0777 & ~umask()`` instead of ``0666 & ~umask()``), and ``UploadedFile::move()`` targeted the parent directory instead of the destination file for ``chmod()``.
6161
- **Helpers:** Fixed a bug where ``get_dir_file_info()`` returned incomplete entries for subdirectories and missing files instead of omitting them.

0 commit comments

Comments
 (0)