18 Commits

Author SHA1 Message Date
chaim d5fe6c21a0 release: v1.2.4 2026-03-29 20:31:11 +00:00
chaim 54e8c77325 fix: proper type coercion for raw SQL import columns
Read column metadata (type, nullable, default) from SHOW COLUMNS and
coerce values accordingly: empty string/false → 0 for NOT NULL numeric
columns, null for nullable ones. Fixes is_internal import error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:31:11 +00:00
chaim 35cda6e91c release: v1.2.3 2026-03-29 20:28:56 +00:00
chaim ba00e7ecf6 fix: convert empty strings to null for integer/bool columns
MySQL strict mode rejects empty string '' for integer columns like
is_internal. Convert '' and false to null before SQL insert.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:28:56 +00:00
chaim e3a1be1844 release: v1.2.2 2026-03-29 20:25:54 +00:00
chaim fd279b6c8b fix: skip columns missing in target DB during raw SQL import
When importing entities with autoincrement (Case, Charge, Invoice),
validate each column exists in the target table before inserting.
Columns from modules not installed on the target are silently skipped
and reported in the import log summary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:25:54 +00:00
chaim 73b5907539 release: v1.2.1 2026-03-29 20:22:05 +00:00
chaim edeef544d4 fix: mysqldump SSL and tablespace errors in snapshot
Add --ssl=false and --no-tablespaces flags to handle Docker MySQL
connections with self-signed certificates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:22:05 +00:00
chaim 1964ad99b6 release: v1.2.0 2026-03-29 20:14:13 +00:00
chaim 078b2e6051 fix: toDb() crash on import + add snapshot before import
- Fix: replace QueryComposer::toDb() (protected) with camelCaseToUnderscore
  for table name resolution — was causing 500 on real import
- Feat: optional database snapshot (mysqldump) before import with UI checkbox
  (checked by default). Snapshot saved to data/backups/snapshot-{timestamp}/
- Feat: skip entity types whose module is not installed (import resilience)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:14:08 +00:00
chaim a57e23dec3 release: v1.1.4 2026-03-29 20:06:18 +00:00
chaim a8c4a2ebe1 feat: add skip attachment files option for import
Allows importing only database records without restoring attachment
files. Useful when migrating data between instances.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:06:12 +00:00
chaim 412b1e643b release: v1.1.3 2026-03-29 20:01:24 +00:00
chaim eed25a87b2 fix: skip entity types whose module is not installed during import
Prevents 500 error when importing a backup that contains entities
(e.g. SignatureRequest) from modules not installed on the target instance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:01:20 +00:00
chaim 838ef5c2c6 release: v1.1.2 2026-03-29 19:51:08 +00:00
chaim 319e2df45e fix: move admin panel item to customDev section
Move Data Migration from system section to unified "Custom Development"
section shared by all custom extensions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:50:59 +00:00
chaim dc2d6c57fe release: v1.1.1 2026-03-29 19:48:11 +00:00
chaim 4c3ec67a7d fix: use __APPEND__ in adminPanel to not overwrite system items
Without __APPEND__, the itemList replaced all existing system admin
panel entries instead of adding to them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:48:07 +00:00
12 changed files with 356 additions and 48 deletions
@@ -150,6 +150,22 @@
<span class="text-muted"> — {{translate 'skipDuplicatesHint' category='messages' scope='DataMigration'}}</span>
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" name="importNoFiles">
{{translate 'Skip attachment files' scope='DataMigration'}}
<span class="text-muted"> — {{translate 'importNoFilesHint' category='messages' scope='DataMigration'}}</span>
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" name="createSnapshot" checked>
{{translate 'Create snapshot before import' scope='DataMigration'}}
<span class="text-muted"> — {{translate 'snapshotHint' category='messages' scope='DataMigration'}}</span>
</label>
</div>
</div>
<div class="btn-group">
@@ -301,6 +301,8 @@ define('modules/data-migration/views/admin/data-migration', ['view'], function (
const preserveIds = this.$el.find('input[name="preserveIds"]').is(':checked');
const skipDuplicates = this.$el.find('input[name="skipDuplicates"]').is(':checked');
const noFiles = this.$el.find('input[name="importNoFiles"]').is(':checked');
const createSnapshot = !dryRun && this.$el.find('input[name="createSnapshot"]').is(':checked');
const confirmMsg = dryRun
? this.translate('dryRunConfirmation', 'messages', 'DataMigration')
@@ -312,36 +314,72 @@ define('modules/data-migration/views/admin/data-migration', ['view'], function (
.addClass('disabled').attr('disabled', true);
this.$el.find('.import-progress').show();
Espo.Ajax.postRequest('DataMigration/action/import', {
backupName: this.selectedBackup,
preserveIds: preserveIds,
skipDuplicates: skipDuplicates,
dryRun: dryRun,
}, {timeout: 600000})
.then(response => {
this.isImporting = false;
this.$el.find('[data-action="import"], [data-action="dryRunImport"]')
.removeClass('disabled').attr('disabled', false);
this.$el.find('.import-progress').hide();
const runImport = () => {
Espo.Ajax.postRequest('DataMigration/action/import', {
backupName: this.selectedBackup,
preserveIds: preserveIds,
noFiles: noFiles,
skipDuplicates: skipDuplicates,
dryRun: dryRun,
}, {timeout: 600000})
.then(response => {
this.isImporting = false;
this.$el.find('[data-action="import"], [data-action="dryRunImport"]')
.removeClass('disabled').attr('disabled', false);
this.$el.find('.import-progress').hide();
this.importLog = response.log || [];
this.renderLog('.import-log-container', this.importLog, !dryRun);
this.importLog = response.log || [];
this.renderLog('.import-log-container', this.importLog, !dryRun);
const label = dryRun ? 'Dry run completed' : 'Import completed';
const label = dryRun ? 'Dry run completed' : 'Import completed';
Espo.Ui.success(this.translate(label, 'labels', 'DataMigration'));
Espo.Ui.success(this.translate(label, 'labels', 'DataMigration'));
if (!dryRun) {
this.loadStatus();
}
})
.catch(xhr => {
this.isImporting = false;
this.$el.find('[data-action="import"], [data-action="dryRunImport"]')
.removeClass('disabled').attr('disabled', false);
this.$el.find('.import-progress').hide();
Espo.Ui.error(this.translate('Import failed', 'labels', 'DataMigration'));
});
if (!dryRun) {
this.loadStatus();
}
})
.catch(xhr => {
this.isImporting = false;
this.$el.find('[data-action="import"], [data-action="dryRunImport"]')
.removeClass('disabled').attr('disabled', false);
this.$el.find('.import-progress').hide();
Espo.Ui.error(this.translate('Import failed', 'labels', 'DataMigration'));
});
};
if (createSnapshot) {
Espo.Ui.notify(this.translate('Creating snapshot...', 'labels', 'DataMigration'));
Espo.Ajax.postRequest('DataMigration/action/snapshot', {}, {timeout: 300000})
.then(response => {
if (response.success) {
Espo.Ui.success(
this.translate('Snapshot created', 'labels', 'DataMigration') +
': ' + response.snapshotName + ' (' + response.dumpSizeFormatted + ')'
);
runImport();
} else {
this.isImporting = false;
this.$el.find('[data-action="import"], [data-action="dryRunImport"]')
.removeClass('disabled').attr('disabled', false);
this.$el.find('.import-progress').hide();
Espo.Ui.error(
this.translate('Snapshot failed', 'labels', 'DataMigration') +
': ' + (response.error || 'Unknown error')
);
}
})
.catch(xhr => {
this.isImporting = false;
this.$el.find('[data-action="import"], [data-action="dryRunImport"]')
.removeClass('disabled').attr('disabled', false);
this.$el.find('.import-progress').hide();
Espo.Ui.error(this.translate('Snapshot failed', 'labels', 'DataMigration'));
});
} else {
runImport();
}
});
},
@@ -46,6 +46,7 @@ class Import implements Action
$preserveIds = $body->preserveIds ?? true;
$skipDuplicates = $body->skipDuplicates ?? true;
$dryRun = $body->dryRun ?? false;
$noFiles = $body->noFiles ?? false;
$io = new BufferedIO();
@@ -54,6 +55,7 @@ class Import implements Action
'preserveIds' => $preserveIds,
'skipDuplicates' => $skipDuplicates,
'dryRun' => $dryRun,
'noFiles' => $noFiles,
'verbose' => true,
], $io);
@@ -0,0 +1,111 @@
<?php
namespace Espo\Modules\DataMigration\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Utils\Config;
use Espo\Entities\User;
class Snapshot implements Action
{
public function __construct(
private User $user,
private Config $config
) {}
public function process(Request $request): Response
{
if (!$this->user->isAdmin()) {
throw new Forbidden();
}
$timestamp = date('Ymd-His');
$snapshotName = 'snapshot-' . $timestamp;
$snapshotDir = 'data/backups/' . $snapshotName;
mkdir($snapshotDir, 0755, true);
$dbParams = $this->config->get('database');
$host = $dbParams['host'] ?? 'localhost';
$port = $dbParams['port'] ?? '3306';
$dbName = $dbParams['dbname'] ?? '';
$user = $dbParams['user'] ?? '';
$password = $dbParams['password'] ?? '';
$dumpFile = $snapshotDir . '/database.sql';
$cmd = sprintf(
'mysqldump --host=%s --port=%s --user=%s --password=%s'
. ' --ssl=false --no-tablespaces --single-transaction --quick %s > %s 2>&1',
escapeshellarg($host),
escapeshellarg($port),
escapeshellarg($user),
escapeshellarg($password),
escapeshellarg($dbName),
escapeshellarg($dumpFile)
);
$output = [];
$returnCode = 0;
exec($cmd, $output, $returnCode);
if ($returnCode !== 0) {
// Cleanup on failure
if (file_exists($dumpFile)) {
unlink($dumpFile);
}
rmdir($snapshotDir);
return ResponseComposer::json([
'success' => false,
'error' => 'Database dump failed: ' . implode("\n", $output),
]);
}
$dumpSize = filesize($dumpFile);
// Write a manifest
$manifest = [
'type' => 'snapshot',
'createdAt' => date('c'),
'sourceHost' => $this->config->get('siteUrl', 'unknown'),
'espoVersion' => $this->config->get('version', 'unknown'),
'database' => $dbName,
'dumpSizeBytes' => $dumpSize,
];
file_put_contents(
$snapshotDir . '/manifest.json',
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
return ResponseComposer::json([
'success' => true,
'snapshotName' => $snapshotName,
'dumpSize' => $dumpSize,
'dumpSizeFormatted' => $this->formatBytes($dumpSize),
]);
}
private function formatBytes(int $bytes): string
{
if ($bytes >= 1073741824) {
return round($bytes / 1073741824, 2) . ' GB';
}
if ($bytes >= 1048576) {
return round($bytes / 1048576, 2) . ' MB';
}
if ($bytes >= 1024) {
return round($bytes / 1024, 2) . ' KB';
}
return $bytes . ' B';
}
}
@@ -25,6 +25,10 @@
"Exporting...": "Exporting...",
"Importing...": "Importing...",
"Log": "Log",
"Create snapshot before import": "Create snapshot before import",
"Creating snapshot...": "Creating database snapshot...",
"Snapshot created": "Snapshot created",
"Snapshot failed": "Snapshot failed",
"records": "records",
"files": "files",
"optional": "optional",
@@ -42,6 +46,8 @@
"deleteBackupConfirmation": "Are you sure you want to delete this backup? This action cannot be undone.",
"noFilesHint": "faster export, without attachment files",
"preserveIdsHint": "keep original record IDs (recommended for same-instance restore)",
"importNoFilesHint": "import only database records, skip attachment files",
"snapshotHint": "backup the entire database before importing, so you can restore if needed",
"skipDuplicatesHint": "skip records that already exist in the database"
}
}
@@ -1,6 +1,7 @@
{
"labels": {
"Data Migration": "העברת נתונים"
"Data Migration": "העברת נתונים",
"Custom Development": "פיתוח עצמאי"
},
"descriptions": {
"dataMigration": "ייצוא וייבוא נתוני תיקים בין מערכות EspoCRM"
@@ -25,6 +25,10 @@
"Exporting...": "מייצא...",
"Importing...": "מייבא...",
"Log": "לוג",
"Create snapshot before import": "צור צילום מצב לפני ייבוא",
"Creating snapshot...": "יוצר צילום מצב של מסד הנתונים...",
"Snapshot created": "צילום מצב נוצר",
"Snapshot failed": "יצירת צילום מצב נכשלה",
"records": "רשומות",
"files": "קבצים",
"optional": "אופציונלי",
@@ -42,6 +46,8 @@
"deleteBackupConfirmation": "האם אתה בטוח שברצונך למחוק גיבוי זה? לא ניתן לבטל פעולה זו.",
"noFilesHint": "ייצוא מהיר יותר, ללא קבצים מצורפים",
"preserveIdsHint": "שמור מזהי רשומות מקוריים (מומלץ לשחזור באותה מערכת)",
"importNoFilesHint": "ייבוא רשומות בלבד, ללא קבצים מצורפים",
"snapshotHint": "גיבוי מלא של מסד הנתונים לפני הייבוא, כדי שתוכל לשחזר במקרה הצורך",
"skipDuplicatesHint": "דלג על רשומות שכבר קיימות במסד הנתונים"
}
}
@@ -1,14 +1,17 @@
{
"system": {
"customDev": {
"label": "Custom Development",
"itemList": [
"__APPEND__",
{
"url": "#Admin/dataMigration",
"label": "Data Migration",
"iconClass": "fas fa-exchange-alt",
"description": "dataMigration",
"view": "modules/data-migration/views/admin/data-migration",
"order": 85
"order": 50
}
]
],
"order": 30
}
}
@@ -23,5 +23,10 @@
"route": "/DataMigration/action/deleteBackup",
"method": "post",
"actionClassName": "Espo\\Modules\\DataMigration\\Api\\DeleteBackup"
},
{
"route": "/DataMigration/action/snapshot",
"method": "post",
"actionClassName": "Espo\\Modules\\DataMigration\\Api\\Snapshot"
}
]
@@ -309,11 +309,19 @@ class EntityImporter
return $newId;
}
/** @var array<string, string[]> Cache of existing column names per table */
private array $tableColumnsCache = [];
/** @var array<string, array<string, array>> Cache of column metadata per table */
private array $tableColumnMetaCache = [];
/** @var array<string, string[]> Skipped columns log per entity type */
private array $skippedColumnsLog = [];
private function insertWithRawSql(string $entityType, array $record, string $newId): void
{
$tableName = $this->entityManager
->getQueryComposer()
->toDb($entityType);
$tableName = $this->camelCaseToUnderscore($entityType);
$existingColumns = $this->getTableColumns($tableName);
// Convert camelCase fields to snake_case for SQL
$columns = [];
@@ -327,15 +335,25 @@ class EntityImporter
continue;
}
// Skip non-storable array/object fields that come from getValueMap
if (is_array($value) || is_object($value)) {
// JSON fields should be stored as JSON strings
if (is_array($value) || is_object($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
$column = $this->camelCaseToUnderscore($field);
// Skip columns that don't exist in the target table
if (!in_array($column, $existingColumns)) {
if (!isset($this->skippedColumnsLog[$entityType][$column])) {
$this->skippedColumnsLog[$entityType][$column] = true;
}
continue;
}
$column = $this->camelCaseToUnderscore($field);
// JSON fields should be stored as JSON strings
if (is_array($value) || is_object($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
}
// Coerce value to match target column type
$value = $this->coerceValue($tableName, $column, $value);
$columns[] = "`{$column}`";
$values[] = $value;
$placeholders[] = '?';
@@ -346,14 +364,85 @@ class EntityImporter
$sql = "INSERT INTO `{$tableName}` ({$columnStr}) VALUES ({$placeholderStr})";
try {
$pdo = $this->entityManager->getPDO();
$stmt = $pdo->prepare($sql);
$stmt->execute($values);
} catch (\Exception $e) {
// If column mismatch, try with just known columns
throw $e;
$pdo = $this->entityManager->getPDO();
$stmt = $pdo->prepare($sql);
$stmt->execute($values);
}
private function getTableColumns(string $tableName): array
{
if (isset($this->tableColumnsCache[$tableName])) {
return $this->tableColumnsCache[$tableName];
}
$pdo = $this->entityManager->getPDO();
$sth = $pdo->query("SHOW COLUMNS FROM `{$tableName}`");
$columns = [];
$meta = [];
while ($row = $sth->fetch(\PDO::FETCH_ASSOC)) {
$columns[] = $row['Field'];
$meta[$row['Field']] = [
'type' => $row['Type'],
'nullable' => $row['Null'] === 'YES',
'default' => $row['Default'],
];
}
$this->tableColumnsCache[$tableName] = $columns;
$this->tableColumnMetaCache[$tableName] = $meta;
return $columns;
}
/**
* Coerce a value to be compatible with the target column type.
*/
private function coerceValue(string $tableName, string $column, mixed $value): mixed
{
$meta = $this->tableColumnMetaCache[$tableName][$column] ?? null;
if ($meta === null) {
return $value;
}
$type = strtolower($meta['type']);
$isNumeric = str_contains($type, 'int') || str_contains($type, 'decimal')
|| str_contains($type, 'float') || str_contains($type, 'double')
|| str_contains($type, 'tinyint') || str_contains($type, 'smallint')
|| str_contains($type, 'bigint');
if ($isNumeric && ($value === '' || $value === false)) {
if ($meta['nullable']) {
return null;
}
return 0;
}
if (!$meta['nullable'] && $value === null) {
if ($isNumeric) {
return 0;
}
return $meta['default'] ?? '';
}
return $value;
}
/**
* @return array<string, string[]> entityType => [skipped column names]
*/
public function getSkippedColumns(): array
{
$result = [];
foreach ($this->skippedColumnsLog as $entityType => $columns) {
$result[$entityType] = array_keys($columns);
}
return $result;
}
private function resetAutoIncrement(string $entityType): void
@@ -62,6 +62,7 @@ class ImportService
$preserveIds = $options['preserveIds'] ?? true;
$skipDuplicates = $options['skipDuplicates'] ?? false;
$dryRun = $options['dryRun'] ?? false;
$noFiles = $options['noFiles'] ?? false;
$verbose = $options['verbose'] ?? false;
$dataDir = rtrim($inputDir, '/') . '/data';
@@ -100,6 +101,11 @@ class ImportService
continue;
}
if (!$this->entityTypeExists($entityType)) {
$io->writeLine("Skipping {$entityType} (module not installed)");
continue;
}
$io->writeLine("Importing {$entityType}...");
$result = $this->entityImporter->import(
@@ -133,7 +139,10 @@ class ImportService
}
// Restore attachment files
if (is_dir($filesDir)) {
if ($noFiles) {
$io->writeLine('');
$io->writeLine('Attachment files: SKIPPED (--no-files)');
} else if (is_dir($filesDir)) {
$io->writeLine('');
$io->writeLine('Restoring attachment files...');
@@ -170,5 +179,27 @@ class ImportService
}
}
}
// Report skipped columns (missing in target DB)
$skippedColumns = $this->entityImporter->getSkippedColumns();
if (!empty($skippedColumns)) {
$io->writeLine('');
$io->writeLine('WARNING: Skipped columns not found in target database:');
foreach ($skippedColumns as $type => $columns) {
$io->writeLine(" {$type}: " . implode(', ', $columns));
}
}
}
private function entityTypeExists(string $entityType): bool
{
try {
$this->entityManager->getRDBRepository($entityType);
return true;
} catch (\Exception $e) {
return false;
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DataMigration",
"version": "1.1.0",
"version": "1.2.4",
"acceptableVersions": [
">=8.0.0"
],