fix: исправлена обработка nullable полей и форматов дат при импорте CSV

- Исправлено определение nullable полей (IS_NULLABLE как boolean)
- Добавлена автоматическая конвертация форматов дат (DD.MM.YYYY → YYYY-MM-DD)
- Улучшена обработка пустых значений в CSV
- Добавлена поддержка различных форматов дат (DD.MM.YYYY, DD/MM/YYYY, DD-MM-YYYY)
- Исправлена логика проверки обязательных полей
This commit is contained in:
2026-01-21 03:48:28 +03:00
parent 66804814f1
commit a5e046e194
2 changed files with 171 additions and 130 deletions

View File

@@ -129,13 +129,11 @@ class DataService
}
}
// ✅ Метод для форматирования ошибок
private function formatPDOError(\PDOException $e, string $schema, string $table, array $params = []): string
{
$code = $e->getCode();
$message = $e->getMessage();
// Foreign Key constraint
if ($code === '23000' && str_contains($message, 'foreign key constraint')) {
if (preg_match('/FOREIGN KEY $$`([^`]+)`$$/', $message, $matches)) {
$field = $matches[1];
@@ -144,7 +142,6 @@ class DataService
return "Ошибка: нарушена связь с другой таблицей. Проверьте правильность заполнения полей-ссылок.";
}
// Duplicate key
if ($code === '23000' && str_contains($message, 'Duplicate entry')) {
if (preg_match('/Duplicate entry \'([^\']+)\' for key \'([^\']+)\'/', $message, $matches)) {
$value = $matches[1];
@@ -154,7 +151,6 @@ class DataService
return "Ошибка: запись с таким значением уже существует.";
}
// 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];
@@ -239,138 +235,176 @@ class DataService
return ['deleted' => $stmt->rowCount()];
}
public function insertMultipleRows(string $schema, string $table, array $rows, array $columns): array
{
error_log("=== НАЧАЛО ИМПОРТА CSV ===");
error_log("Schema: $schema, Table: $table");
error_log("Количество строк для импорта: " . count($rows));
error_log("Структура первой строки: " . json_encode($rows[0] ?? []));
public function insertMultipleRows(string $schema, string $table, array $rows, array $columns): array
{
error_log("=== НАЧАЛО ИМПОРТА CSV ===");
error_log("Schema: $schema, Table: $table");
error_log("Количество строк для импорта: " . count($rows));
if (empty($rows)) {
return ['inserted' => 0, 'errors' => 0, 'message' => 'No rows provided'];
}
$inserted = 0;
$errors = 0;
$errorMessages = [];
// ✅ Собираем информацию о всех столбцах таблицы
$validColumns = [];
foreach ($columns as $c) {
$name = $c['COLUMN_NAME'];
$extra = $c['EXTRA'] ?? '';
error_log("Столбец: $name, Extra: $extra, Nullable: " . ($c['IS_NULLABLE'] ? 'YES' : 'NO') . ", Default: " . ($c['COLUMN_DEFAULT'] ?? 'NULL'));
// Пропускаем только auto_increment поля
if (!str_contains($extra, 'auto_increment')) {
$validColumns[$name] = [
'nullable' => $c['IS_NULLABLE'] ?? false,
'has_default' => !empty($c['COLUMN_DEFAULT']) || $c['COLUMN_DEFAULT'] === '0',
'default' => $c['COLUMN_DEFAULT'] ?? null
];
} else {
error_log("Пропускаем auto_increment столбец: $name");
if (empty($rows)) {
return ['inserted' => 0, 'errors' => 0, 'message' => 'No rows provided'];
}
}
error_log("Валидные столбцы для импорта: " . json_encode(array_keys($validColumns)));
$inserted = 0;
$errors = 0;
$errorMessages = [];
$this->pdo->beginTransaction();
// ✅ Собираем информацию о всех столбцах таблицы
$validColumns = [];
foreach ($columns as $c) {
$name = $c['COLUMN_NAME'];
$extra = $c['EXTRA'] ?? '';
$dataType = strtolower($c['DATA_TYPE'] ?? '');
try {
foreach ($rows as $index => $row) {
error_log("\n--- Обработка строки " . ($index + 1) . " ---");
error_log("Данные строки: " . json_encode($row));
// ✅ Правильная проверка IS_NULLABLE
$isNullable = ($c['IS_NULLABLE'] === true || $c['IS_NULLABLE'] === 'YES');
try {
$insertCols = [];
$placeholders = [];
$params = [];
error_log("Столбец: $name, Type: $dataType, Extra: $extra, Nullable: " . ($isNullable ? 'YES' : 'NO') . ", Default: " . ($c['COLUMN_DEFAULT'] ?? 'NULL'));
// ✅ Перебираем ВСЕ столбцы таблицы (кроме auto_increment)
foreach ($validColumns as $name => $info) {
$value = null;
// Если столбец есть в CSV строке
if (array_key_exists($name, $row)) {
$value = $row[$name];
error_log(" Столбец '$name' найден в CSV: '$value'");
// ✅ Обрабатываем "NULL" как NULL
if (in_array($value, ['NULL', 'null', ''], true)) {
error_log(" Преобразуем '$value' в NULL");
$value = null;
}
} else {
error_log(" Столбец '$name' НЕ найден в CSV");
// ✅ Столбца нет в CSV - устанавливаем NULL или пропускаем
// Если поле имеет значение по умолчанию - пропускаем (БД сама подставит)
if ($info['has_default']) {
error_log(" Пропускаем (есть default): " . $info['default']);
continue;
}
// Если поле nullable - ставим NULL
if ($info['nullable']) {
error_log(" Устанавливаем NULL (nullable)");
$value = null;
} else {
// Поле обязательное и нет дефолта - ошибка
error_log(" ОШИБКА: обязательное поле без значения");
throw new \PDOException("Missing required field: $name");
}
}
$insertCols[] = "`$name`";
$placeholders[] = ":$name";
$params[":$name"] = $value;
error_log(" Добавлено в INSERT: '$name' = " . ($value === null ? 'NULL' : "'$value'"));
}
if (empty($insertCols)) {
error_log("Нет столбцов для вставки, пропускаем строку");
continue;
}
$sql = sprintf(
"INSERT INTO `%s`.`%s` (%s) VALUES (%s)",
$schema, $table,
implode(',', $insertCols),
implode(',', $placeholders)
);
error_log("SQL: $sql");
error_log("Параметры: " . json_encode($params));
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$inserted++;
error_log("✓ Строка успешно вставлена, ID: " . $this->pdo->lastInsertId());
} catch (\PDOException $e) {
$errors++;
$errorMsg = "Строка " . ($index + 1) . ": " . $this->formatPDOError($e, $schema, $table);
error_log("✗ ОШИБКА при вставке строки " . ($index + 1) . ": " . $e->getMessage());
error_log(" SQL Code: " . $e->getCode());
error_log(" SQL State: " . ($e->errorInfo[0] ?? 'unknown'));
$errorMessages[] = $errorMsg;
// Пропускаем только 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
];
} else {
error_log("Пропускаем auto_increment столбец: $name");
}
}
$this->pdo->commit();
error_log("=== ИМПОРТ ЗАВЕРШЁН: вставлено=$inserted, ошибок=$errors ===");
} catch (\Exception $e) {
$this->pdo->rollBack();
error_log("=== КРИТИЧЕСКАЯ ОШИБКА ИМПОРТА: " . $e->getMessage() . " ===");
throw new \RuntimeException('Import failed: ' . $e->getMessage());
error_log("Валидные столбцы для импорта: " . json_encode(array_keys($validColumns)));
$this->pdo->beginTransaction();
try {
foreach ($rows as $index => $row) {
error_log("\n--- Обработка строки " . ($index + 1) . " ---");
error_log("Данные строки: " . json_encode($row));
try {
$insertCols = [];
$placeholders = [];
$params = [];
// ✅ Перебираем ВСЕ столбцы таблицы (кроме auto_increment)
foreach ($validColumns as $name => $info) {
$value = null;
// Если столбец есть в CSV строке
if (array_key_exists($name, $row)) {
$value = $row[$name];
error_log(" Столбец '$name' найден в CSV: '" . var_export($value, true) . "'");
// ✅ Обрабатываем пустые значения как NULL
if ($value === null || $value === '' || in_array($value, ['NULL', 'null'], true)) {
error_log(" Преобразуем '$value' в NULL");
$value = null;
} else {
// ✅ Конвертируем форматы дат для date/datetime полей
if (in_array($info['data_type'], ['date', 'datetime', 'timestamp'])) {
$value = $this->convertDateFormat($value);
error_log(" Конвертация даты: результат = '$value'");
}
}
} else {
error_log(" Столбец '$name' НЕ найден в CSV");
// ✅ Столбца нет в CSV - устанавливаем NULL или пропускаем
// Если поле имеет значение по умолчанию - пропускаем (БД сама подставит)
if ($info['has_default']) {
error_log(" Пропускаем (есть default): " . $info['default']);
continue;
}
// Если поле nullable - ставим NULL
if ($info['nullable']) {
error_log(" Устанавливаем NULL (nullable)");
$value = null;
} else {
// Поле обязательное и нет дефолта - ошибка
error_log(" ОШИБКА: обязательное поле без значения (nullable={$info['nullable']})");
throw new \PDOException("Missing required field: $name");
}
}
$insertCols[] = "`$name`";
$placeholders[] = ":$name";
$params[":$name"] = $value;
error_log(" Добавлено в INSERT: '$name' = " . ($value === null ? 'NULL' : "'$value'"));
}
if (empty($insertCols)) {
error_log("Нет столбцов для вставки, пропускаем строку");
continue;
}
$sql = sprintf(
"INSERT INTO `%s`.`%s` (%s) VALUES (%s)",
$schema, $table,
implode(',', $insertCols),
implode(',', $placeholders)
);
error_log("SQL: $sql");
error_log("Параметры: " . json_encode($params));
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$inserted++;
error_log("✓ Строка успешно вставлена, ID: " . $this->pdo->lastInsertId());
} catch (\PDOException $e) {
$errors++;
$errorMsg = "Строка " . ($index + 1) . ": " . $this->formatPDOError($e, $schema, $table);
error_log("✗ ОШИБКА при вставке строки " . ($index + 1) . ": " . $e->getMessage());
error_log(" SQL Code: " . $e->getCode());
$errorMessages[] = $errorMsg;
}
}
$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
];
}
return [
'inserted' => $inserted,
'errors' => $errors,
'errorMessages' => $errorMessages
];
}
/**
* Конвертирует различные форматы дат в формат MySQL (YYYY-MM-DD)
*/
private function convertDateFormat(string $date): string
{
$date = trim($date);
// Уже в нужном формате YYYY-MM-DD
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $date)) {
return substr($date, 0, 10);
}
// Формат DD.MM.YYYY или DD/MM/YYYY или DD-MM-YYYY
if (preg_match('/^(\d{1,2})[\.\/-](\d{1,2})[\.\/-](\d{4})/', $date, $matches)) {
$day = str_pad($matches[1], 2, '0', STR_PAD_LEFT);
$month = str_pad($matches[2], 2, '0', STR_PAD_LEFT);
$year = $matches[3];
return "$year-$month-$day";
}
// Пробуем распарсить через strtotime
$timestamp = strtotime($date);
if ($timestamp !== false) {
return date('Y-m-d', $timestamp);
}
// Если не удалось распарсить - возвращаем как есть (будет ошибка от БД)
return $date;
}
public function exportCSV(
string $schema,

View File

@@ -79,25 +79,32 @@ class MetaService
$enrichedCols = array_map(function($c) use ($fkMap) {
$name = $c['COLUMN_NAME'];
// ✅ Явно конвертируем IS_NULLABLE в boolean
$isNullable = ($c['IS_NULLABLE'] === 'YES');
// ✅ Проверка наличия дефолтного значения
$hasDefault = ($c['COLUMN_DEFAULT'] !== null) ||
str_contains(strtoupper($c['EXTRA'] ?? ''), 'DEFAULT_GENERATED');
return [
'COLUMN_NAME' => $name,
'DATA_TYPE' => $c['DATA_TYPE'],
'COLUMN_TYPE' => $c['COLUMN_TYPE'],
'COLUMN_KEY' => $c['COLUMN_KEY'],
'IS_NULLABLE' => $c['IS_NULLABLE'] === 'YES',
'IS_NULLABLE' => $isNullable, // ✅ Boolean, не строка
'COLUMN_DEFAULT' => $c['COLUMN_DEFAULT'],
'HAS_DEFAULT' => !empty($c['COLUMN_DEFAULT']),
'HAS_DEFAULT' => $hasDefault,
'EXTRA' => $c['EXTRA'],
'IS_AUTO_INCREMENT' => str_contains($c['EXTRA'] ?? '', 'auto_increment'),
'ORDINAL_POSITION' => (int)$c['ORDINAL_POSITION'],
'IS_REQUIRED' => $c['IS_NULLABLE'] === 'NO' && empty($c['COLUMN_DEFAULT']),
'IS_REQUIRED' => !$isNullable && !$hasDefault && !str_contains($c['EXTRA'] ?? '', 'auto_increment'), // ✅ Правильная логика
'EDITOR_TYPE' => $this->getEditorType($c),
// ✅ Добавляем информацию о FK
'IS_FOREIGN_KEY' => isset($fkMap[$name]),
'FOREIGN_KEY' => $fkMap[$name] ?? null
];
}, $cols);
return [
'columns' => $enrichedCols,
'primaryKey' => $pk,