feat: улучшена информативность ошибок при импорте CSV
- В сообщениях об ошибках теперь отображается содержимое проблемной строки - Добавлена специальная обработка ошибки Data truncated (обрезание данных) - Показывается конкретное значение поля, вызвавшего ошибку - Добавлена информация о типе и размере столбца при truncation - Улучшено форматирование вывода ошибок (JSON для больших данных) - Номер строки теперь соответствует номеру в исходном CSV файле (+2 для учета заголовка)
This commit is contained in:
@@ -129,38 +129,62 @@ class DataService
|
||||
}
|
||||
}
|
||||
|
||||
private function formatPDOError(\PDOException $e, string $schema, string $table, array $params = []): string
|
||||
{
|
||||
$code = $e->getCode();
|
||||
$message = $e->getMessage();
|
||||
|
||||
if ($code === '23000' && str_contains($message, 'foreign key constraint')) {
|
||||
if (preg_match('/FOREIGN KEY $$`([^`]+)`$$/', $message, $matches)) {
|
||||
$field = $matches[1];
|
||||
return "Ошибка: поле '{$field}' должно содержать существующее значение из связанной таблицы.";
|
||||
private function formatPDOError(\PDOException $e, string $schema, string $table, array $params = []): string
|
||||
{
|
||||
$code = $e->getCode();
|
||||
$message = $e->getMessage();
|
||||
|
||||
// Data truncated (обрезание данных)
|
||||
if ($code === '01000' && str_contains($message, 'Data truncated')) {
|
||||
if (preg_match('/column \'([^\']+)\'/', $message, $matches)) {
|
||||
$field = $matches[1];
|
||||
$value = 'N/A';
|
||||
|
||||
// Пытаемся найти значение в параметрах
|
||||
foreach ($params as $paramName => $paramValue) {
|
||||
if (str_contains($paramName, $field)) {
|
||||
$value = $paramValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return "Ошибка: нарушена связь с другой таблицей. Проверьте правильность заполнения полей-ссылок.";
|
||||
}
|
||||
|
||||
if ($code === '23000' && str_contains($message, 'Duplicate entry')) {
|
||||
if (preg_match('/Duplicate entry \'([^\']+)\' for key \'([^\']+)\'/', $message, $matches)) {
|
||||
$value = $matches[1];
|
||||
$key = $matches[2];
|
||||
return "Ошибка: значение '{$value}' уже существует (ключ '{$key}').";
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return "Ошибка: поле '{$field}' - значение '{$value}' слишком большое или имеет неверный формат.";
|
||||
}
|
||||
return "Ошибка: запись с таким значением уже существует.";
|
||||
|
||||
return "Ошибка: поле '{$field}' - данные обрезаны для значения '{$value}'.";
|
||||
}
|
||||
|
||||
if (str_contains($message, "cannot be null") || str_contains($message, "doesn't have a default value")) {
|
||||
if (preg_match('/Column \'([^\']+)\'/', $message, $matches)) {
|
||||
$field = $matches[1];
|
||||
return "Ошибка: поле '{$field}' обязательно для заполнения.";
|
||||
}
|
||||
return "Ошибка: не заполнены обязательные поля.";
|
||||
}
|
||||
|
||||
return "Ошибка БД: " . $message;
|
||||
return "Ошибка: данные обрезаны при вставке.";
|
||||
}
|
||||
|
||||
if ($code === '23000' && str_contains($message, 'foreign key constraint')) {
|
||||
if (preg_match('/FOREIGN KEY $$`([^`]+)`$$/', $message, $matches)) {
|
||||
$field = $matches[1];
|
||||
return "Ошибка: поле '{$field}' должно содержать существующее значение из связанной таблицы.";
|
||||
}
|
||||
return "Ошибка: нарушена связь с другой таблицей. Проверьте правильность заполнения полей-ссылок.";
|
||||
}
|
||||
|
||||
if ($code === '23000' && str_contains($message, 'Duplicate entry')) {
|
||||
if (preg_match('/Duplicate entry \'([^\']+)\' for key \'([^\']+)\'/', $message, $matches)) {
|
||||
$value = $matches[1];
|
||||
$key = $matches[2];
|
||||
return "Ошибка: значение '{$value}' уже существует (ключ '{$key}').";
|
||||
}
|
||||
return "Ошибка: запись с таким значением уже существует.";
|
||||
}
|
||||
|
||||
if (str_contains($message, "cannot be null") || str_contains($message, "doesn't have a default value")) {
|
||||
if (preg_match('/Column \'([^\']+)\'/', $message, $matches)) {
|
||||
$field = $matches[1];
|
||||
return "Ошибка: поле '{$field}' обязательно для заполнения.";
|
||||
}
|
||||
return "Ошибка: не заполнены обязательные поля.";
|
||||
}
|
||||
|
||||
return "Ошибка БД: " . $message;
|
||||
}
|
||||
|
||||
|
||||
public function updateRow(string $schema, string $table, array $row, array $columns, array $pk): array
|
||||
{
|
||||
@@ -235,122 +259,250 @@ class DataService
|
||||
return ['deleted' => $stmt->rowCount()];
|
||||
}
|
||||
|
||||
public function insertMultipleRows(string $schema, string $table, array $rows, array $columns): array
|
||||
{
|
||||
error_log("=== ИМПОРТ CSV: Schema=$schema, Table=$table, Строк=" . count($rows) . " ===");
|
||||
|
||||
if (empty($rows)) {
|
||||
return ['inserted' => 0, 'errors' => 0, 'message' => 'No rows provided'];
|
||||
}
|
||||
public function insertMultipleRows(string $schema, string $table, array $rows, array $columns): array
|
||||
{
|
||||
error_log("=== ИМПОРТ CSV: Schema=$schema, Table=$table, Строк=" . count($rows) . " ===");
|
||||
|
||||
if (empty($rows)) {
|
||||
return ['inserted' => 0, 'errors' => 0, 'message' => 'No rows provided'];
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
$errors = 0;
|
||||
$errorMessages = [];
|
||||
$inserted = 0;
|
||||
$errors = 0;
|
||||
$errorMessages = [];
|
||||
|
||||
// Собираем информацию о всех столбцах таблицы
|
||||
$validColumns = [];
|
||||
foreach ($columns as $c) {
|
||||
$name = $c['COLUMN_NAME'];
|
||||
$extra = $c['EXTRA'] ?? '';
|
||||
$dataType = strtolower($c['DATA_TYPE'] ?? '');
|
||||
|
||||
$isNullable = ($c['IS_NULLABLE'] === true || $c['IS_NULLABLE'] === 'YES');
|
||||
|
||||
// Пропускаем только auto_increment поля
|
||||
if (!str_contains($extra, 'auto_increment')) {
|
||||
$validColumns[$name] = [
|
||||
'nullable' => $isNullable,
|
||||
'has_default' => !empty($c['COLUMN_DEFAULT']) || $c['COLUMN_DEFAULT'] === '0',
|
||||
'default' => $c['COLUMN_DEFAULT'] ?? null,
|
||||
'data_type' => $dataType
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($rows as $index => $row) {
|
||||
try {
|
||||
$insertCols = [];
|
||||
$placeholders = [];
|
||||
$params = [];
|
||||
|
||||
// Перебираем ВСЕ столбцы таблицы (кроме auto_increment)
|
||||
foreach ($validColumns as $name => $info) {
|
||||
$value = null;
|
||||
|
||||
// Если столбец есть в CSV строке
|
||||
if (array_key_exists($name, $row)) {
|
||||
$value = $row[$name];
|
||||
|
||||
// Обрабатываем пустые значения как NULL
|
||||
if ($value === null || $value === '' || in_array($value, ['NULL', 'null'], true)) {
|
||||
$value = null;
|
||||
} else {
|
||||
// Конвертируем форматы дат для date/datetime полей
|
||||
if (in_array($info['data_type'], ['date', 'datetime', 'timestamp'])) {
|
||||
$value = $this->convertDateFormat($value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Столбца нет в CSV
|
||||
if ($info['has_default']) {
|
||||
continue;
|
||||
}
|
||||
if ($info['nullable']) {
|
||||
$value = null;
|
||||
} else {
|
||||
throw new \PDOException("Missing required field: $name");
|
||||
}
|
||||
}
|
||||
|
||||
$insertCols[] = "`$name`";
|
||||
$placeholders[] = ":$name";
|
||||
$params[":$name"] = $value;
|
||||
// Собираем информацию о всех столбцах таблицы
|
||||
$validColumns = [];
|
||||
$columnTypes = []; // Для детальной информации об ошибках
|
||||
|
||||
// Предварительная проверка данных
|
||||
$warnings = [];
|
||||
foreach ($rows as $index => $row) {
|
||||
foreach ($row as $field => $value) {
|
||||
if (isset($columnTypes[$field]) && is_numeric($value)) {
|
||||
$type = $columnTypes[$field]['data_type'];
|
||||
|
||||
// Проверка для INT типов
|
||||
if (str_contains($type, 'int')) {
|
||||
if ($value > 2147483647) { // INT max value
|
||||
$warnings[] = "Строка CSV #" . ($index + 2) . ": поле '$field' = '$value' превышает максимальное значение для типа INT";
|
||||
}
|
||||
|
||||
if (empty($insertCols)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = sprintf(
|
||||
"INSERT INTO `%s`.`%s` (%s) VALUES (%s)",
|
||||
$schema, $table,
|
||||
implode(',', $insertCols),
|
||||
implode(',', $placeholders)
|
||||
);
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$inserted++;
|
||||
} catch (\PDOException $e) {
|
||||
$errors++;
|
||||
$errorMsg = "Строка " . ($index + 1) . ": " . $this->formatPDOError($e, $schema, $table);
|
||||
$errorMessages[] = $errorMsg;
|
||||
|
||||
// Логируем только первые 10 ошибок
|
||||
if ($errors <= 10) {
|
||||
error_log("Ошибка импорта строки " . ($index + 1) . ": " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Проверка для DECIMAL/FLOAT
|
||||
if (str_contains($type, 'decimal') || str_contains($type, 'float')) {
|
||||
if (strlen((string)$value) > 15) {
|
||||
$warnings[] = "Строка CSV #" . ($index + 2) . ": поле '$field' = '$value' может быть слишком большим";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
error_log("=== ИМПОРТ ЗАВЕРШЁН: вставлено=$inserted, ошибок=$errors ===");
|
||||
} catch (\Exception $e) {
|
||||
$this->pdo->rollBack();
|
||||
error_log("=== КРИТИЧЕСКАЯ ОШИБКА ИМПОРТА: " . $e->getMessage() . " ===");
|
||||
throw new \RuntimeException('Import failed: ' . $e->getMessage());
|
||||
if (!empty($warnings) && count($warnings) <= 20) {
|
||||
error_log("Предупреждения перед импортом:\n" . implode("\n", $warnings));
|
||||
}
|
||||
|
||||
foreach ($columns as $c) {
|
||||
$name = $c['COLUMN_NAME'];
|
||||
$extra = $c['EXTRA'] ?? '';
|
||||
$dataType = strtolower($c['DATA_TYPE'] ?? '');
|
||||
|
||||
$isNullable = ($c['IS_NULLABLE'] === true || $c['IS_NULLABLE'] === 'YES');
|
||||
|
||||
// Сохраняем информацию о типе столбца
|
||||
$columnTypes[$name] = [
|
||||
'type' => $c['COLUMN_TYPE'] ?? $dataType,
|
||||
'data_type' => $dataType
|
||||
];
|
||||
|
||||
// Пропускаем только auto_increment поля
|
||||
if (!str_contains($extra, 'auto_increment')) {
|
||||
$validColumns[$name] = [
|
||||
'nullable' => $isNullable,
|
||||
'has_default' => !empty($c['COLUMN_DEFAULT']) || $c['COLUMN_DEFAULT'] === '0',
|
||||
'default' => $c['COLUMN_DEFAULT'] ?? null,
|
||||
'data_type' => $dataType
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
foreach ($rows as $index => $row) {
|
||||
try {
|
||||
$insertCols = [];
|
||||
$placeholders = [];
|
||||
$params = [];
|
||||
|
||||
// Перебираем ВСЕ столбцы таблицы (кроме auto_increment)
|
||||
foreach ($validColumns as $name => $info) {
|
||||
$value = null;
|
||||
|
||||
// Если столбец есть в CSV строке
|
||||
if (array_key_exists($name, $row)) {
|
||||
$value = $row[$name];
|
||||
|
||||
// Обрабатываем пустые значения как NULL
|
||||
if ($value === null || $value === '' || in_array($value, ['NULL', 'null'], true)) {
|
||||
$value = null;
|
||||
} else {
|
||||
// Конвертируем форматы дат для date/datetime полей
|
||||
if (in_array($info['data_type'], ['date', 'datetime', 'timestamp'])) {
|
||||
$value = $this->convertDateFormat($value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Столбца нет в CSV
|
||||
if ($info['has_default']) {
|
||||
continue;
|
||||
}
|
||||
if ($info['nullable']) {
|
||||
$value = null;
|
||||
} else {
|
||||
throw new \PDOException("Missing required field: $name");
|
||||
}
|
||||
}
|
||||
|
||||
$insertCols[] = "`$name`";
|
||||
$placeholders[] = ":$name";
|
||||
$params[":$name"] = $value;
|
||||
}
|
||||
|
||||
if (empty($insertCols)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sql = sprintf(
|
||||
"INSERT INTO `%s`.`%s` (%s) VALUES (%s)",
|
||||
$schema, $table,
|
||||
implode(',', $insertCols),
|
||||
implode(',', $placeholders)
|
||||
);
|
||||
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$inserted++;
|
||||
} catch (\PDOException $e) {
|
||||
$errors++;
|
||||
|
||||
// ✅ Формируем детальное сообщение об ошибке с данными строки
|
||||
$csvLineNumber = $index + 2; // +2 потому что: +1 для человеческой нумерации, +1 для строки заголовка
|
||||
$errorMsg = $this->formatImportError($e, $csvLineNumber, $row, $columnTypes);
|
||||
$errorMessages[] = $errorMsg;
|
||||
|
||||
// Логируем только первые 10 ошибок
|
||||
if ($errors <= 10) {
|
||||
error_log("Ошибка импорта строки CSV #$csvLineNumber: " . $e->getMessage());
|
||||
error_log("Данные строки: " . json_encode($row, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'inserted' => $inserted,
|
||||
'errors' => $errors,
|
||||
'errorMessages' => $errorMessages
|
||||
];
|
||||
$this->pdo->commit();
|
||||
error_log("=== ИМПОРТ ЗАВЕРШЁН: вставлено=$inserted, ошибок=$errors ===");
|
||||
} catch (\Exception $e) {
|
||||
$this->pdo->rollBack();
|
||||
error_log("=== КРИТИЧЕСКАЯ ОШИБКА ИМПОРТА: " . $e->getMessage() . " ===");
|
||||
throw new \RuntimeException('Import failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return [
|
||||
'inserted' => $inserted,
|
||||
'errors' => $errors,
|
||||
'errorMessages' => $errorMessages
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует ошибку импорта с детальной информацией
|
||||
*/
|
||||
private function formatImportError(\PDOException $e, int $lineNumber, array $rowData, array $columnTypes): string
|
||||
{
|
||||
$code = $e->getCode();
|
||||
$message = $e->getMessage();
|
||||
|
||||
$baseMsg = "Строка CSV #{$lineNumber}: ";
|
||||
|
||||
// Обработка Data truncated (обрезание данных)
|
||||
if ($code === '01000' && str_contains($message, 'Data truncated')) {
|
||||
if (preg_match('/column \'([^\']+)\'/', $message, $matches)) {
|
||||
$field = $matches[1];
|
||||
$value = $rowData[$field] ?? 'N/A';
|
||||
$columnType = $columnTypes[$field]['type'] ?? 'unknown';
|
||||
|
||||
// Проверяем, не слишком ли большое число
|
||||
if (is_numeric($value)) {
|
||||
$valueLength = strlen((string)$value);
|
||||
return $baseMsg . "Поле '{$field}' ({$columnType}): значение '{$value}' (длина: {$valueLength}) слишком большое или имеет неверный формат. " .
|
||||
"Данные строки: " . $this->formatRowData($rowData);
|
||||
}
|
||||
|
||||
return $baseMsg . "Поле '{$field}' ({$columnType}): данные обрезаны для значения '{$value}'. " .
|
||||
"Данные строки: " . $this->formatRowData($rowData);
|
||||
}
|
||||
return $baseMsg . "Данные обрезаны. Строка: " . $this->formatRowData($rowData);
|
||||
}
|
||||
|
||||
// Foreign Key constraint
|
||||
if ($code === '23000' && str_contains($message, 'foreign key constraint')) {
|
||||
if (preg_match('/FOREIGN KEY $$`([^`]+)`$$/', $message, $matches)) {
|
||||
$field = $matches[1];
|
||||
$value = $rowData[$field] ?? 'N/A';
|
||||
return $baseMsg . "Поле '{$field}' = '{$value}': значение не существует в связанной таблице. " .
|
||||
"Данные: " . $this->formatRowData($rowData);
|
||||
}
|
||||
return $baseMsg . "Нарушена связь с другой таблицей. Данные: " . $this->formatRowData($rowData);
|
||||
}
|
||||
|
||||
// Duplicate key
|
||||
if ($code === '23000' && str_contains($message, 'Duplicate entry')) {
|
||||
if (preg_match('/Duplicate entry \'([^\']+)\' for key \'([^\']+)\'/', $message, $matches)) {
|
||||
$value = $matches[1];
|
||||
$key = $matches[2];
|
||||
return $baseMsg . "Дубликат: значение '{$value}' (ключ '{$key}') уже существует. " .
|
||||
"Данные: " . $this->formatRowData($rowData);
|
||||
}
|
||||
return $baseMsg . "Дубликат записи. Данные: " . $this->formatRowData($rowData);
|
||||
}
|
||||
|
||||
// NOT NULL constraint
|
||||
if (str_contains($message, "cannot be null") || str_contains($message, "doesn't have a default value")) {
|
||||
if (preg_match('/Column \'([^\']+)\'/', $message, $matches)) {
|
||||
$field = $matches[1];
|
||||
return $baseMsg . "Поле '{$field}' обязательно для заполнения. " .
|
||||
"Данные: " . $this->formatRowData($rowData);
|
||||
}
|
||||
return $baseMsg . "Не заполнены обязательные поля. Данные: " . $this->formatRowData($rowData);
|
||||
}
|
||||
|
||||
// Общая ошибка БД
|
||||
return $baseMsg . "Ошибка БД: " . $message . ". Данные: " . $this->formatRowData($rowData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует данные строки для вывода (сокращает длинные значения)
|
||||
*/
|
||||
private function formatRowData(array $rowData): string
|
||||
{
|
||||
$formatted = [];
|
||||
foreach ($rowData as $key => $value) {
|
||||
if ($value === null) {
|
||||
$formatted[] = "$key=NULL";
|
||||
} else {
|
||||
// Ограничиваем длину значения для удобства чтения
|
||||
$strValue = (string)$value;
|
||||
if (strlen($strValue) > 50) {
|
||||
$strValue = substr($strValue, 0, 50) . '...';
|
||||
}
|
||||
$formatted[] = "$key='$strValue'";
|
||||
}
|
||||
}
|
||||
return implode(', ', $formatted);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Конвертирует различные форматы дат в формат MySQL (YYYY-MM-DD)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user