phpmyadmin/src/Plugins/Import/ImportCsv.php
Kamil Tekiela 384f47ffc8 Remove __('Options')
It was not used anywhere.

Signed-off-by: Kamil Tekiela <tekiela246@gmail.com>
2026-02-26 00:24:47 +00:00

809 lines
28 KiB
PHP

<?php
/**
* CSV import plugin for phpMyAdmin
*
* @todo add an option for handling NULL values
*/
declare(strict_types=1);
namespace PhpMyAdmin\Plugins\Import;
use PhpMyAdmin\Current;
use PhpMyAdmin\File;
use PhpMyAdmin\Html\Generator;
use PhpMyAdmin\Http\ServerRequest;
use PhpMyAdmin\Import\Import;
use PhpMyAdmin\Import\ImportSettings;
use PhpMyAdmin\Import\ImportTable;
use PhpMyAdmin\Message;
use PhpMyAdmin\Properties\Options\Groups\OptionsPropertyRootGroup;
use PhpMyAdmin\Properties\Options\Items\BoolPropertyItem;
use PhpMyAdmin\Properties\Options\Items\NumberPropertyItem;
use PhpMyAdmin\Properties\Options\Items\TextPropertyItem;
use PhpMyAdmin\Properties\Plugins\ImportPluginProperties;
use PhpMyAdmin\ResponseRenderer;
use PhpMyAdmin\Util;
use function __;
use function array_map;
use function array_pad;
use function array_shift;
use function count;
use function implode;
use function in_array;
use function max;
use function min;
use function pathinfo;
use function preg_split;
use function rtrim;
use function str_contains;
use function strlen;
use function strtr;
use function substr;
use function trim;
use const PATHINFO_FILENAME;
/**
* Handles the import for the CSV format
*/
class ImportCsv extends AbstractImportCsv
{
private bool $intoExistingTable = true;
private bool $replace = false;
private bool $ignore = false;
private string $terminated = '';
private string $enclosed = '';
private string $escaped = '';
private string $newLine = '';
private string $columns = '';
private int $maxLines = 0;
private bool $csvHasColumnNames = false;
private string $newDatabaseName = '';
private string $newTableName = '';
/** @psalm-return non-empty-lowercase-string */
public function getName(): string
{
return 'csv';
}
protected function setProperties(): ImportPluginProperties
{
$this->intoExistingTable = ImportSettings::$importType === 'table';
$importPluginProperties = new ImportPluginProperties();
$importPluginProperties->setText('CSV');
$importPluginProperties->setExtension('csv');
// create the root group that will be the options field for
// $importPluginProperties
// this will be shown as "Format specific options"
$importSpecificOptions = new OptionsPropertyRootGroup('Format Specific Options');
$generalOptions = $this->getGeneralOptions();
if (ImportSettings::$importType !== 'table') {
$leaf = new TextPropertyItem(
'csv_new_tbl_name',
__(
'Name of the new table (optional):',
),
);
$generalOptions->addProperty($leaf);
if (ImportSettings::$importType === 'server') {
$leaf = new TextPropertyItem(
'csv_new_db_name',
__(
'Name of the new database (optional):',
),
);
$generalOptions->addProperty($leaf);
}
$leaf = new NumberPropertyItem(
'csv_partial_import',
__(
'Import these many number of rows (optional):',
),
);
$generalOptions->addProperty($leaf);
$leaf = new BoolPropertyItem(
'csv_col_names',
__(
'The first line of the file contains the table column names'
. ' <i>(if this is unchecked, the first line will become part'
. ' of the data)</i>',
),
);
$generalOptions->addProperty($leaf);
} else {
$leaf = new NumberPropertyItem(
'csv_partial_import',
__(
'Import these many number of rows (optional):',
),
);
$generalOptions->addProperty($leaf);
$hint = new Message(
__(
'If the data in each row of the file is not'
. ' in the same order as in the database, list the corresponding'
. ' column names here. Column names must be separated by commas'
. ' and not enclosed in quotations.',
),
);
$leaf = new TextPropertyItem(
'csv_columns',
__('Column names:') . ' ' . Generator::showHint($hint->getMessage()),
);
$generalOptions->addProperty($leaf);
}
$leaf = new BoolPropertyItem(
'csv_ignore',
__('Do not abort on INSERT error'),
);
$generalOptions->addProperty($leaf);
// add the main group to the root group
$importSpecificOptions->addProperty($generalOptions);
// set the options for the import plugin property item
$importPluginProperties->setOptions($importSpecificOptions);
return $importPluginProperties;
}
public function setImportOptions(ServerRequest $request): void
{
$this->replace = $request->getParsedBodyParam('csv_replace') !== null;
$this->ignore = $request->getParsedBodyParam('csv_ignore') !== null;
$this->terminated = $request->getParsedBodyParamAsString('csv_terminated', '');
$this->enclosed = $request->getParsedBodyParamAsString('csv_enclosed', '');
$this->escaped = $request->getParsedBodyParamAsString('csv_escaped', '');
$this->newLine = $request->getParsedBodyParamAsString('csv_new_line', '');
$this->columns = $request->getParsedBodyParamAsString('csv_columns', '');
$this->maxLines = min(0, (int) $request->getParsedBodyParamAsStringOrNull('csv_partial_import'));
$this->csvHasColumnNames = $request->getParsedBodyParam('csv_col_names') !== null;
$this->newDatabaseName = $request->getParsedBodyParamAsString('csv_new_db_name', '');
$this->newTableName = $request->getParsedBodyParamAsString('csv_new_tbl_name', '');
}
/**
* Handles the whole import logic
*
* @return string[]
*
* @infection-ignore-all
*/
public function doImport(File|null $importHandle = null): array
{
$replacements = ['\\n' => "\n", '\\t' => "\t", '\\r' => "\r"];
$this->terminated = strtr($this->terminated, $replacements);
$this->enclosed = strtr($this->enclosed, $replacements);
$this->escaped = strtr($this->escaped, $replacements);
$this->newLine = strtr($this->newLine, $replacements);
[Import::$hasError, Current::$message] = $this->buildErrorsForParams(
$this->terminated,
$this->enclosed,
$this->escaped,
$this->newLine,
Import::$errorUrl,
);
$fields = $this->getSqlRequiredFields(Current::$database, Current::$table);
$sqlTemplate = $this->getSqlTemplate(Current::$database, Current::$table, $fields);
$sqlStatements = [];
// Defaults for parser
$i = 0;
$len = 0;
$lastlen = 0;
$line = 1;
$lasti = -1;
$values = [];
$csvFinish = false;
$maxLinesConstraint = $this->maxLines + 1;
// if the first row has to be counted as column names, include one more row in the max lines
if ($this->csvHasColumnNames) {
$maxLinesConstraint++;
}
$rows = [];
$buffer = '';
$maxCols = 0;
$csvTerminatedLen = strlen($this->terminated);
while (! (ImportSettings::$finished && $i >= $len) && ! Import::$hasError && ! ImportSettings::$timeoutPassed) {
$data = $this->import->getNextChunk($importHandle);
if ($data === false) {
// subtract data we didn't handle yet and stop processing
ImportSettings::$offset -= strlen($buffer);
break;
}
if ($data !== true) {
// Append new data to buffer
$buffer .= $data;
unset($data);
// Force a trailing new line at EOF to prevent parsing problems
if (ImportSettings::$finished && $buffer) {
$finalch = substr($buffer, -1);
if ($this->newLine === 'auto' && $finalch !== "\r" && $finalch !== "\n") {
$buffer .= "\n";
} elseif ($this->newLine !== 'auto' && $finalch !== $this->newLine) {
$buffer .= $this->newLine;
}
}
// Do not parse string when we're not at the end
// and don't have new line inside
if (
($this->newLine === 'auto'
&& ! str_contains($buffer, "\r")
&& ! str_contains($buffer, "\n"))
|| ($this->newLine !== 'auto'
&& ! str_contains($buffer, $this->newLine))
) {
continue;
}
}
// Current length of our buffer
$len = strlen($buffer);
// Currently parsed char
$ch = $buffer[$i];
if ($csvTerminatedLen > 1 && $ch === $this->terminated[0]) {
$ch .= substr($buffer, $i + 1, $csvTerminatedLen - 1);
$i += $csvTerminatedLen - 1;
}
while ($i < $len) {
// Deadlock protection
if ($lasti === $i && $lastlen === $len) {
Current::$message = Message::error(
__('Invalid format of CSV input on line %d.'),
);
Current::$message->addParam($line);
Import::$hasError = true;
break;
}
$lasti = $i;
$lastlen = $len;
// This can happen with auto EOL and \r at the end of buffer
if (! $csvFinish) {
// Grab empty field
if ($ch === $this->terminated) {
if ($i === $len - 1) {
break;
}
$values[] = '';
$i++;
$ch = $buffer[$i];
if ($csvTerminatedLen > 1 && $ch === $this->terminated[0]) {
$ch .= substr($buffer, $i + 1, $csvTerminatedLen - 1);
$i += $csvTerminatedLen - 1;
}
continue;
}
// Grab one field
$fallbacki = $i;
if ($ch === $this->enclosed) {
if ($i === $len - 1) {
break;
}
$needEnd = true;
$i++;
$ch = $buffer[$i];
if ($csvTerminatedLen > 1 && $ch === $this->terminated[0]) {
$ch .= substr($buffer, $i + 1, $csvTerminatedLen - 1);
$i += $csvTerminatedLen - 1;
}
} else {
$needEnd = false;
}
$fail = false;
$value = '';
while (
($needEnd
&& ($ch != $this->enclosed
|| $this->enclosed === $this->escaped))
|| (! $needEnd
&& ! ($ch === $this->terminated
|| $ch === $this->newLine
|| ($this->newLine === 'auto'
&& ($ch === "\r" || $ch === "\n"))))
) {
if ($ch === $this->escaped) {
if ($i === $len - 1) {
$fail = true;
break;
}
$i++;
$ch = $buffer[$i];
if ($csvTerminatedLen > 1 && $ch === $this->terminated[0]) {
$ch .= substr($buffer, $i + 1, $csvTerminatedLen - 1);
$i += $csvTerminatedLen - 1;
}
if (
$this->enclosed === $this->escaped
&& ($ch === $this->terminated
|| $ch === $this->newLine
|| ($this->newLine === 'auto'
&& ($ch === "\r" || $ch === "\n")))
) {
break;
}
}
$value .= $ch;
if ($i === $len - 1) {
if (! ImportSettings::$finished) {
$fail = true;
}
break;
}
$i++;
$ch = $buffer[$i];
if ($csvTerminatedLen <= 1 || $ch != $this->terminated[0]) {
continue;
}
$ch .= substr($buffer, $i + 1, $csvTerminatedLen - 1);
$i += $csvTerminatedLen - 1;
}
// unquoted NULL string
if ($needEnd === false && $value === 'NULL') {
$value = null;
}
if ($fail) {
$i = $fallbacki;
$ch = $buffer[$i];
if ($csvTerminatedLen > 1 && $ch === $this->terminated[0]) {
$i += $csvTerminatedLen - 1;
}
break;
}
// Need to strip trailing enclosing char?
if ($needEnd && $ch === $this->enclosed) {
if (ImportSettings::$finished && $i === $len - 1) {
$ch = null;
} elseif ($i === $len - 1) {
$i = $fallbacki;
$ch = $buffer[$i];
if ($csvTerminatedLen > 1 && $ch === $this->terminated[0]) {
$i += $csvTerminatedLen - 1;
}
break;
} else {
$i++;
$ch = $buffer[$i];
if ($csvTerminatedLen > 1 && $ch === $this->terminated[0]) {
$ch .= substr($buffer, $i + 1, $csvTerminatedLen - 1);
$i += $csvTerminatedLen - 1;
}
}
}
// Are we at the end?
if (
$ch === $this->newLine
|| ($this->newLine === 'auto' && ($ch === "\r" || $ch === "\n"))
|| (ImportSettings::$finished && $i === $len - 1)
) {
$csvFinish = true;
}
// Go to next char
if ($ch === $this->terminated) {
if ($i === $len - 1) {
$i = $fallbacki;
$ch = $buffer[$i];
if ($csvTerminatedLen > 1 && $ch === $this->terminated[0]) {
$i += $csvTerminatedLen - 1;
}
break;
}
$i++;
$ch = $buffer[$i];
if ($csvTerminatedLen > 1 && $ch === $this->terminated[0]) {
$ch .= substr($buffer, $i + 1, $csvTerminatedLen - 1);
$i += $csvTerminatedLen - 1;
}
}
// If everything went okay, store value
$values[] = $value;
}
// End of line
if (
! $csvFinish
&& $ch != $this->newLine
&& ($this->newLine !== 'auto' || ($ch !== "\r" && $ch !== "\n"))
) {
continue;
}
if ($this->newLine === 'auto' && $ch === "\r") { // Handle "\r\n"
if ($i >= ($len - 2) && ! ImportSettings::$finished) {
break; // We need more data to decide new line
}
if (substr($buffer, $i + 1, 1) === "\n") {
$i++;
}
}
// We didn't parse value till the end of line, so there was
// empty one
if (! $csvFinish) {
$values[] = '';
}
if (! $this->intoExistingTable) {
$maxCols = max($maxCols, count($values));
$rows[] = $values;
} else {
// Do we have correct count of values?
if (count($values) !== count($fields)) {
// Hack for excel
if ($values[count($values) - 1] !== ';') {
Current::$message = Message::error(
__(
'Invalid column count in CSV input on line %d.',
),
);
Current::$message->addParam($line);
Import::$hasError = true;
break;
}
unset($values[count($values) - 1]);
}
$sqlStatements = $this->addRowToDatabase($values, $sqlTemplate, $fields, $sqlStatements);
}
$line++;
$csvFinish = false;
$values = [];
$buffer = substr($buffer, $i + 1);
$len = strlen($buffer);
$i = 0;
$lasti = -1;
$ch = substr($buffer, 0, 1);
if ($this->maxLines > 0 && $line === $maxLinesConstraint) {
ImportSettings::$finished = true;
break;
}
}
if ($this->maxLines > 0 && $line === $maxLinesConstraint) {
ImportSettings::$finished = true;
break;
}
}
if (! $this->intoExistingTable) {
$this->buildSqlStructures($rows, $maxCols, $sqlStatements);
}
// Commit any possible data in buffers
$this->import->runQuery('', $sqlStatements);
if ($values === [] || Import::$hasError) {
return $sqlStatements;
}
Current::$message = Message::error(
__('Invalid format of CSV input on line %d.'),
);
Current::$message->addParam($line);
Import::$hasError = true;
return $sqlStatements;
}
/**
* @param (string|null)[] $values
* @param string[] $fields
* @param string[] $sqlStatements
*
* @return string[]
*/
private function addRowToDatabase(array $values, string $sqlTemplate, array $fields, array $sqlStatements): array
{
$quotedValues = [];
foreach ($values as $val) {
$quotedValues[] = $val === null ? 'NULL' : $this->dbi->quoteString($val);
}
$sql = $sqlTemplate . implode(', ', $quotedValues) . ')';
if ($this->replace) {
$sql .= ' ON DUPLICATE KEY UPDATE ';
foreach ($fields as $field) {
$fieldName = Util::backquote($field);
$sql .= $fieldName . ' = VALUES(' . $fieldName . '), ';
}
$sql = rtrim($sql, ', ');
}
/**
* @todo maybe we could add original line to verbose
* SQL in comment
*/
$this->import->runQuery($sql, $sqlStatements);
return $sqlStatements;
}
/**
* @param list<list<string|null>> $rows
* @param string[] $sqlStatements
*/
private function buildSqlStructures(array $rows, int $maxCols, array &$sqlStatements): void
{
/* Fill out all rows */
foreach ($rows as $i => $row) {
$rows[$i] = array_pad($row, $maxCols, 'NULL');
}
$colNames = [];
/* Remove the first row if it contains the column names */
if ($this->csvHasColumnNames) {
$colNames = array_shift($rows);
}
$colNames = $this->getColumnNames($colNames, $maxCols);
$tblName = $this->getTableNameFromImport(Current::$database);
$table = new ImportTable($tblName, $colNames, $rows);
/* Obtain the best-fit MySQL types for each column */
$analysis = $this->import->analyzeTable($table);
/**
* Set database name to the currently selected one, if applicable,
* Otherwise, check if user provided the database name in the request,
* if not, set the default name
*/
if ($this->newDatabaseName !== '') {
$newDb = $this->newDatabaseName;
} else {
$result = $this->dbi->fetchSingleColumn('SHOW DATABASES');
$newDb = 'CSV_DB ' . (count($result) + 1);
}
$dbName = Current::$database !== '' ? Current::$database : $newDb;
$createDb = Current::$database === '';
if ($createDb) {
$sqlStatements = $this->import->createDatabase($dbName, 'utf8', 'utf8_general_ci', $sqlStatements);
}
$this->import->buildSql(
$dbName,
[$table],
[$analysis],
sqlData: $sqlStatements,
insertMode: $this->ignore ? 'INSERT IGNORE' : 'INSERT',
);
}
/** @return array{bool, Message|null} */
private function buildErrorsForParams(
string $csvTerminated,
string $csvEnclosed,
string $csvEscaped,
string $csvNewLine,
string $errUrl,
): array {
$paramError = false;
if ($csvTerminated === '') {
Current::$message = Message::error(
__('Invalid parameter for CSV import: %s'),
);
Current::$message->addParam(__('Columns terminated with'));
Import::$hasError = true;
$paramError = true;
// The default dialog of MS Excel when generating a CSV produces a
// semi-colon-separated file with no chance of specifying the
// enclosing character. Thus, users who want to import this file
// tend to remove the enclosing character on the Import dialog.
// I could not find a test case where having no enclosing characters
// confuses this script.
// But the parser won't work correctly with strings so we allow just
// one character.
} elseif (strlen($csvEnclosed) > 1) {
Current::$message = Message::error(
__('Invalid parameter for CSV import: %s'),
);
Current::$message->addParam(__('Columns enclosed with'));
Import::$hasError = true;
$paramError = true;
// I could not find a test case where having no escaping characters
// confuses this script.
// But the parser won't work correctly with strings so we allow just
// one character.
} elseif (strlen($csvEscaped) > 1) {
Current::$message = Message::error(
__('Invalid parameter for CSV import: %s'),
);
Current::$message->addParam(__('Columns escaped with'));
Import::$hasError = true;
$paramError = true;
} elseif (strlen($csvNewLine) !== 1 && $csvNewLine !== 'auto') {
Current::$message = Message::error(
__('Invalid parameter for CSV import: %s'),
);
Current::$message->addParam(__('Lines terminated with'));
Import::$hasError = true;
$paramError = true;
}
// If there is an error in the parameters entered,
// indicate that immediately.
if ($paramError) {
$errorMessage = Generator::mysqlDie(Current::$message->getMessage(), '', false);
$response = ResponseRenderer::getInstance();
if ($response->isAjax()) {
$response->setRequestStatus(false);
$response->addJSON('message', $errorMessage);
$response->callExit();
}
$response->addHTML($errorMessage . Generator::getBackUrlHtml($errUrl));
$response->callExit();
}
return [Import::$hasError, Current::$message];
}
private function getTableNameFromImport(string $databaseName): string
{
// get new table name, if user didn't provide one, set the default name
if ($this->newTableName !== '') {
return $this->newTableName;
}
return $this->import->getNextAvailableTableName(
$databaseName,
pathinfo(ImportSettings::$importFileName, PATHINFO_FILENAME),
);
}
/**
* @param list<string> $columnNames
*
* @return list<string>
*/
private function getColumnNames(array $columnNames, int $maxCols): array
{
if ($this->csvHasColumnNames) {
// MySQL column names can't end with a space character.
$columnNames = array_map(rtrim(...), $columnNames);
}
if (count($columnNames) !== $maxCols) {
// Fill out column names
/** @infection-ignore-all */
for ($i = 0; $i < $maxCols; ++$i) {
$columnNames[] = 'COL ' . ($i + 1);
}
}
return $columnNames;
}
/** @return string[] */
private function getSqlRequiredFields(
string|null $db,
string|null $table,
): array {
if (! $this->intoExistingTable || $db === null || $table === null) {
return [];
}
$tmpFields = $this->dbi->getColumnNames($db, $table);
if ($this->columns === '') {
return $tmpFields;
}
$fields = [];
$tmp = preg_split('/,( ?)/', $this->columns);
if ($tmp === false) {
$tmp = [];
}
foreach ($tmp as $val) {
/* Trim also `, if user already included backquoted fields */
$val = trim($val, " \t\r\n\0\x0B`");
if (! in_array($val, $tmpFields, true)) {
Current::$message = Message::error(
__(
'Invalid column (%s) specified! Ensure that columns'
. ' names are spelled correctly, separated by commas'
. ', and not enclosed in quotes.',
),
);
Current::$message->addParam($val);
Import::$hasError = true;
break;
}
$fields[] = $val;
}
return $fields;
}
/** @param string[] $fields */
private function getSqlTemplate(
string|null $db,
string|null $table,
array $fields,
): string {
if (! $this->intoExistingTable || $db === null || $table === null) {
return '';
}
$sqlTemplate = 'INSERT';
if ($this->ignore) {
$sqlTemplate .= ' IGNORE';
}
$sqlTemplate .= ' INTO ' . Util::backquote($table);
if ($this->columns !== '') {
$sqlTemplate .= ' (';
$sqlTemplate .= implode(
', ',
array_map(Util::backquote(...), $fields),
);
$sqlTemplate .= ') ';
}
$sqlTemplate .= ' VALUES (';
return $sqlTemplate;
}
}