06abaf40d4
- Implementato controllo di sicurezza per bloccare query non SELECT - Aggiunta funzione IsSelectQuery() con validazione robusta - Bloccate operazioni pericolose: INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE, EXEC, etc. - Implementata pulizia query per prevenire bypass tramite commenti SQL - Aggiunto controllo per multiple statements separati da ; - Aggiunto avviso di sicurezza nell'interfaccia utente - Implementato logging di sicurezza per audit e debug - Controllo applicato sia in validazione che in esecuzione per doppia protezione Il sistema ora garantisce che solo query SELECT possano essere eseguite, mantenendo la sicurezza del database e prevenendo operazioni di modifica non autorizzate.
3160 lines
157 KiB
Plaintext
3160 lines
157 KiB
Plaintext
@page "/data-coupler"
|
|
@using CredentialManager.Models
|
|
@using DataConnection.Interfaces
|
|
@using DataConnection.CredentialManagement.Interfaces
|
|
@using DataConnection.REST.Interfaces
|
|
@using DataConnection.REST.Models
|
|
@using Data_Coupler.Services
|
|
@using Microsoft.AspNetCore.Components.Forms
|
|
@using Microsoft.JSInterop
|
|
@using System.IO
|
|
@using System.Text
|
|
@using System.Data
|
|
@using ExcelDataReader
|
|
@inject IDataConnectionCredentialService CredentialService
|
|
@inject IDataConnectionFactory ConnectionFactory
|
|
@inject IJSRuntime JSRuntime
|
|
@inject ILogger<DataCoupler> Logger
|
|
|
|
<PageTitle>Data Coupler</PageTitle>
|
|
|
|
<div class="container-fluid">
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<h3><i class="fas fa-exchange-alt"></i> Data Coupler - Coupling Database e REST API</h3>
|
|
<p class="text-muted">Connetti database e servizi REST per il trasferimento dati</p>
|
|
</div>
|
|
</div> <div class="row">
|
|
<!-- Lato Sinistro - Fonte Dati -->
|
|
<div class="col-md-6">
|
|
<div class="card h-100">
|
|
<div class="card-header bg-primary text-white">
|
|
<h5><i class="fas fa-database"></i> Fonte Dati</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<!-- Selezione Tipo Fonte -->
|
|
<div class="mb-3">
|
|
<label class="form-label">Tipo di Fonte:</label>
|
|
<select class="form-select" @onchange="OnSourceTypeChanged" value="@selectedSourceType">
|
|
<option value="">-- Seleziona Tipo --</option>
|
|
<option value="database">Database</option>
|
|
<option value="file">File (Excel/CSV)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Sezione Database -->
|
|
@if (selectedSourceType == "database")
|
|
{
|
|
<!-- Selezione Credenziali Database -->
|
|
<div class="mb-3">
|
|
<label class="form-label">Credenziali Database:</label>
|
|
<select class="form-select" @onchange="OnDatabaseCredentialChanged" value="@selectedDatabaseCredential">
|
|
<option value="">-- Seleziona Database --</option>
|
|
@foreach (var cred in databaseCredentials)
|
|
{
|
|
<option value="@cred.Name">@cred.Name (@cred.DatabaseType - @cred.Host)</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrEmpty(selectedDatabaseCredential))
|
|
{
|
|
<div class="mb-3">
|
|
<button class="btn btn-success btn-sm" @onclick="ConnectToDatabase" disabled="@isConnectingDatabase">
|
|
@if (isConnectingDatabase)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
|
}
|
|
<i class="fas fa-plug"></i> Connetti e Scopri Schema
|
|
</button>
|
|
@if (isDatabaseConnected)
|
|
{
|
|
<span class="badge bg-success ms-2">Connesso</span>
|
|
}
|
|
</div>
|
|
} @if (!string.IsNullOrEmpty(databaseErrorMessage))
|
|
{
|
|
<div class="alert alert-danger" role="alert">
|
|
@databaseErrorMessage
|
|
</div>
|
|
}
|
|
|
|
<!-- Lista Tabelle -->
|
|
@if (isDatabaseConnected)
|
|
{
|
|
<!-- Selezione modalità: Tabelle o Query Custom -->
|
|
<div class="mb-3">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" role="switch" id="useCustomQuerySwitch"
|
|
@onchange="OnQueryModeChanged" checked="@useCustomQuery">
|
|
<label class="form-check-label" for="useCustomQuerySwitch">
|
|
<i class="fas fa-code"></i> Usa Query SQL Custom
|
|
</label>
|
|
</div>
|
|
<small class="text-muted">
|
|
Scegli se selezionare una tabella o scrivere una query SQL personalizzata
|
|
</small>
|
|
</div>
|
|
|
|
@if (useCustomQuery)
|
|
{
|
|
<!-- Sezione Query Custom -->
|
|
<div class="mb-3">
|
|
<h6>Query SQL Custom:</h6>
|
|
|
|
<div class="mb-2">
|
|
<label class="form-label">Scrivi la tua query SELECT:</label>
|
|
<textarea class="form-control" rows="6" placeholder="SELECT * FROM your_table WHERE condition..."
|
|
@bind="customQuery" @bind:event="oninput"></textarea>
|
|
<div class="mt-2">
|
|
<div class="alert alert-warning d-flex align-items-start" role="alert">
|
|
<i class="fas fa-shield-alt me-2 mt-1"></i>
|
|
<div>
|
|
<strong>Controlli di Sicurezza Attivi:</strong><br>
|
|
<small>
|
|
• Solo query <strong>SELECT</strong> sono permesse<br>
|
|
• Operazioni come INSERT, UPDATE, DELETE, DROP sono bloccate<br>
|
|
• Query multiple separate da ; non sono consentite<br>
|
|
• La query verrà automaticamente ottimizzata per il trasferimento dati
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-2">
|
|
<button class="btn btn-primary btn-sm me-2" @onclick="ValidateCustomQuery"
|
|
disabled="@(isValidatingQuery || string.IsNullOrWhiteSpace(customQuery))">
|
|
@if (isValidatingQuery)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
|
}
|
|
<i class="fas fa-check-circle"></i> Valida Query
|
|
</button>
|
|
|
|
@if (isQueryValid)
|
|
{
|
|
<button class="btn btn-info btn-sm me-2" @onclick="LoadQueryPreview"
|
|
disabled="@isLoadingPreview">
|
|
@if (isLoadingPreview)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
|
}
|
|
<i class="fas fa-eye"></i> Anteprima Risultati
|
|
</button>
|
|
|
|
@if (showQueryPreview)
|
|
{
|
|
<button class="btn btn-outline-secondary btn-sm" @onclick="HideQueryPreview">
|
|
<i class="fas fa-eye-slash"></i> Nascondi Anteprima
|
|
</button>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
@if (!string.IsNullOrEmpty(queryValidationMessage) && !isQueryValid)
|
|
{
|
|
<div class="alert alert-danger" role="alert">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
@queryValidationMessage
|
|
</div>
|
|
}
|
|
|
|
<!-- Anteprima risultati query -->
|
|
@if (showQueryPreview && queryPreviewData.Any())
|
|
{
|
|
<div class="card mt-3">
|
|
<div class="card-header">
|
|
<h6 class="mb-0">
|
|
<i class="fas fa-table"></i> Anteprima Risultati Query
|
|
<span class="badge bg-info ms-2">@queryPreviewData.Count righe</span>
|
|
</h6>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<div class="table-responsive" style="max-height: 400px;">
|
|
<table class="table table-striped table-hover mb-0">
|
|
<thead class="table-dark sticky-top">
|
|
<tr>
|
|
@if (queryColumns.Any())
|
|
{
|
|
@foreach (var column in queryColumns)
|
|
{
|
|
<th>@column</th>
|
|
}
|
|
}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var row in queryPreviewData.Take(20))
|
|
{
|
|
<tr>
|
|
@foreach (var column in queryColumns)
|
|
{
|
|
<td>
|
|
@(row.ContainsKey(column) ? row[column]?.ToString() ?? "" : "")
|
|
</td>
|
|
}
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
@if (queryPreviewData.Count > 20)
|
|
{
|
|
<div class="card-footer text-muted">
|
|
<small>
|
|
<i class="fas fa-info-circle"></i>
|
|
Mostrate prime 20 righe di @queryPreviewData.Count risultati totali
|
|
</small>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
else if (databaseTables.Any())
|
|
{
|
|
<!-- Sezione Tabelle (modalità standard) -->
|
|
<div class="mb-3">
|
|
<h6>Tabelle Database (@databaseTables.Count disponibili):</h6>
|
|
|
|
<!-- Campo di ricerca -->
|
|
<div class="mb-2">
|
|
<div class="input-group">
|
|
<span class="input-group-text">
|
|
<i class="fas fa-search"></i>
|
|
</span>
|
|
<input type="text" class="form-control" placeholder="Cerca tabelle..."
|
|
@bind="databaseSearchTerm" @oninput="FilterDatabaseTables" />
|
|
@if (!string.IsNullOrEmpty(databaseSearchTerm))
|
|
{
|
|
<button class="btn btn-outline-secondary" @onclick="ClearDatabaseSearch">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lista tabelle filtrate -->
|
|
<div class="list-group" style="max-height: 300px; overflow-y: auto;">
|
|
@foreach (var table in GetFilteredDatabaseTables())
|
|
{
|
|
<a class="list-group-item list-group-item-action @(selectedTable == table ? "active" : "")"
|
|
@onclick="@(() => SelectTable(table))">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<i class="fas fa-table"></i> @table
|
|
@if (databaseTables.ContainsKey(table))
|
|
{
|
|
<small class="text-muted d-block">@databaseTables[table].Count() campi</small>
|
|
}
|
|
</div>
|
|
@if (selectedTable == table)
|
|
{
|
|
<span class="badge bg-primary">Selezionata</span>
|
|
}
|
|
</div>
|
|
</a>
|
|
}
|
|
</div>
|
|
|
|
@if (!GetFilteredDatabaseTables().Any())
|
|
{
|
|
<div class="alert alert-info mt-2">
|
|
<i class="fas fa-info-circle"></i> Nessuna tabella trovata con il termine di ricerca "@databaseSearchTerm"
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
}
|
|
|
|
<!-- Sezione File -->
|
|
@if (selectedSourceType == "file")
|
|
{
|
|
<div class="mb-3">
|
|
<label class="form-label">Seleziona File (Excel/CSV):</label>
|
|
<InputFile class="form-control" OnChange="OnFileSelected" accept=".xlsx,.xls,.csv" />
|
|
@if (!string.IsNullOrEmpty(selectedFileName))
|
|
{
|
|
<small class="text-muted">File selezionato: @selectedFileName</small>
|
|
}
|
|
</div>
|
|
|
|
@if (isProcessingFile)
|
|
{
|
|
<div class="mb-3">
|
|
<div class="d-flex align-items-center">
|
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
|
Elaborazione file in corso...
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (!string.IsNullOrEmpty(fileErrorMessage))
|
|
{
|
|
<div class="alert alert-danger" role="alert">
|
|
@fileErrorMessage
|
|
</div>
|
|
} <!-- Lista Fogli/Sheet -->
|
|
@if (fileSheets.Any())
|
|
{
|
|
<div class="mb-3">
|
|
@{
|
|
var fileExtension = Path.GetExtension(selectedFileName).ToLowerInvariant();
|
|
var isExcel = fileExtension == ".xlsx" || fileExtension == ".xls";
|
|
var fileTypeIcon = isExcel ? "fa-file-excel text-success" : "fa-file-csv text-info";
|
|
var fileTypeName = isExcel ? "Excel" : "CSV";
|
|
}
|
|
<h6>
|
|
<i class="fas @fileTypeIcon"></i>
|
|
Fogli @fileTypeName (@fileSheets.Count):
|
|
</h6>
|
|
|
|
<div class="list-group" style="max-height: 300px; overflow-y: auto;">
|
|
@foreach (var sheet in fileSheets)
|
|
{
|
|
<a class="list-group-item list-group-item-action @(selectedSheet == sheet.Key ? "active" : "")"
|
|
@onclick="@(() => SelectSheet(sheet.Key))">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<i class="fas @fileTypeIcon"></i> @sheet.Key
|
|
<small class="text-muted d-block">@sheet.Value.Count() colonne</small>
|
|
</div>
|
|
@if (selectedSheet == sheet.Key)
|
|
{
|
|
<span class="badge bg-primary">Selezionato</span>
|
|
}
|
|
</div>
|
|
</a>
|
|
} </div>
|
|
</div>
|
|
} else if (selectedSourceType == "file" && !string.IsNullOrEmpty(selectedFileName) && !isProcessingFile && string.IsNullOrEmpty(fileErrorMessage))
|
|
{
|
|
<div class="alert alert-info" role="alert">
|
|
<i class="fas fa-info-circle"></i>
|
|
<strong>File selezionato:</strong> @selectedFileName
|
|
<br><small>Il file è stato elaborato ma non contiene dati validi o fogli leggibili.</small>
|
|
</div>
|
|
}
|
|
else if (selectedSourceType == "file" && string.IsNullOrEmpty(selectedFileName))
|
|
{
|
|
<div class="alert alert-light border" role="alert">
|
|
<div class="text-center">
|
|
<i class="fas fa-upload fa-2x text-muted mb-2"></i>
|
|
<h6>Carica un file per iniziare</h6>
|
|
<small class="text-muted">
|
|
<strong>Formati supportati:</strong><br>
|
|
• <i class="fas fa-file-excel text-success"></i> Excel (.xlsx, .xls) - Supporto completo per fogli multipli<br>
|
|
• <i class="fas fa-file-csv text-info"></i> CSV (.csv) - Prima riga come intestazioni<br><br>
|
|
Una volta caricato, potrai visualizzare immediatamente il contenuto e navigare tra tutti i record
|
|
</small>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Anteprima Dati File -->
|
|
@if (!string.IsNullOrEmpty(selectedSheet) && fileData.ContainsKey(selectedSheet) && fileData[selectedSheet].Any())
|
|
{
|
|
<div class="mb-3">
|
|
<div class="alert alert-success" role="alert">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
@{
|
|
var fileExtension = Path.GetExtension(selectedFileName).ToLowerInvariant();
|
|
var isExcel = fileExtension == ".xlsx" || fileExtension == ".xls";
|
|
var fileTypeIcon = isExcel ? "fa-file-excel text-white" : "fa-file-csv text-white";
|
|
var fileTypeName = isExcel ? "Excel" : "CSV";
|
|
}
|
|
<i class="fas fa-check-circle"></i> <strong>File @fileTypeName Caricato!</strong>
|
|
<div class="mt-1">
|
|
<small>
|
|
<i class="fas @fileTypeIcon"></i>
|
|
Foglio: <strong>@selectedSheet</strong> |
|
|
Record: <strong>@fileData[selectedSheet].Count</strong> |
|
|
Colonne: <strong>@fileSheets[selectedSheet].Count()</strong>
|
|
</small>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<div class="d-flex align-items-center">
|
|
<small class="me-2">Righe per pagina:</small>
|
|
<select class="form-select form-select-sm" style="width: auto;"
|
|
@onchange="OnPageSizeChanged" value="@pageSize">
|
|
<option value="10">10</option>
|
|
<option value="20">20</option>
|
|
<option value="50">50</option>
|
|
<option value="100">100</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn btn-outline-primary btn-sm" type="button"
|
|
data-bs-toggle="collapse" data-bs-target="#fileDataPreview"
|
|
aria-expanded="true" aria-controls="fileDataPreview">
|
|
<i class="fas fa-eye"></i> Mostra/Nascondi Preview
|
|
</button>
|
|
</div>
|
|
</div> </div>
|
|
|
|
<div class="collapse show mt-2" id="fileDataPreview">
|
|
<div class="card">
|
|
<div class="card-header bg-light">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<h6 class="mb-0"><i class="fas fa-table"></i> Preview Dati - @selectedSheet</h6>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<small class="text-muted">
|
|
Record @GetStartRecord()-@GetEndRecord() di @fileData[selectedSheet].Count
|
|
</small>
|
|
<div class="btn-group btn-group-sm" role="group">
|
|
<button type="button" class="btn btn-outline-secondary"
|
|
@onclick="FirstPage"
|
|
disabled="@(currentPage == 1)">
|
|
<i class="fas fa-angle-double-left"></i>
|
|
</button>
|
|
<button type="button" class="btn btn-outline-secondary"
|
|
@onclick="PreviousPage"
|
|
disabled="@(currentPage == 1)">
|
|
<i class="fas fa-angle-left"></i>
|
|
</button>
|
|
<span class="btn btn-outline-secondary disabled">
|
|
@currentPage di @GetTotalPages(selectedSheet)
|
|
</span>
|
|
<button type="button" class="btn btn-outline-secondary"
|
|
@onclick="NextPage"
|
|
disabled="@(currentPage >= GetTotalPages(selectedSheet))">
|
|
<i class="fas fa-angle-right"></i>
|
|
</button>
|
|
<button type="button" class="btn btn-outline-secondary"
|
|
@onclick="LastPage"
|
|
disabled="@(currentPage >= GetTotalPages(selectedSheet))">
|
|
<i class="fas fa-angle-double-right"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0" style="max-height: 400px; overflow: auto;">
|
|
@if (fileSheets.ContainsKey(selectedSheet))
|
|
{
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-striped table-hover mb-0">
|
|
<thead class="table-dark sticky-top">
|
|
<tr>
|
|
<th style="width: 50px;">#</th>
|
|
@foreach (var column in fileSheets[selectedSheet])
|
|
{
|
|
<th>@column</th>
|
|
}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@{
|
|
var dataToShow = GetCurrentPageData();
|
|
var startRecord = GetStartRecord();
|
|
}
|
|
@for (int i = 0; i < dataToShow.Count; i++)
|
|
{
|
|
var row = dataToShow[i];
|
|
var absoluteRowIndex = startRecord + i;
|
|
<tr>
|
|
<td><small class="text-muted">@absoluteRowIndex</small></td>
|
|
@foreach (var column in fileSheets[selectedSheet])
|
|
{
|
|
var cellValue = row.ContainsKey(column) ? row[column]?.ToString() : "";
|
|
var displayValue = string.IsNullOrEmpty(cellValue) ? "-" :
|
|
(cellValue.Length > 50 ? cellValue.Substring(0, 50) + "..." : cellValue);
|
|
<td>
|
|
<span title="@cellValue">@displayValue</span>
|
|
</td>
|
|
}
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<!-- Footer con controlli paginazione e informazioni -->
|
|
<div class="card-footer bg-light">
|
|
<div class="row align-items-center">
|
|
<div class="col-md-6">
|
|
<small class="text-muted">
|
|
<i class="fas fa-info-circle"></i>
|
|
Visualizzazione: @pageSize record per pagina
|
|
</small>
|
|
</div>
|
|
<div class="col-md-6 text-end">
|
|
<div class="btn-group btn-group-sm" role="group">
|
|
<button type="button" class="btn btn-outline-primary"
|
|
@onclick="FirstPage"
|
|
disabled="@(currentPage == 1)">
|
|
Prima
|
|
</button>
|
|
<button type="button" class="btn btn-outline-primary"
|
|
@onclick="PreviousPage"
|
|
disabled="@(currentPage == 1)">
|
|
Precedente
|
|
</button>
|
|
<button type="button" class="btn btn-outline-primary"
|
|
@onclick="NextPage"
|
|
disabled="@(currentPage >= GetTotalPages(selectedSheet))">
|
|
Successiva
|
|
</button>
|
|
<button type="button" class="btn btn-outline-primary"
|
|
@onclick="LastPage"
|
|
disabled="@(currentPage >= GetTotalPages(selectedSheet))">
|
|
Ultima
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div> }
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lato Destro - REST API -->
|
|
<div class="col-md-6">
|
|
<div class="card h-100">
|
|
<div class="card-header bg-info text-white">
|
|
<h5><i class="fas fa-cloud"></i> REST API Destination</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<!-- Selezione Credenziali REST -->
|
|
<div class="mb-3">
|
|
<label class="form-label">Credenziali REST API:</label>
|
|
<select class="form-select" @onchange="OnRestCredentialChanged" value="@selectedRestCredential">
|
|
<option value="">-- Seleziona REST API --</option>
|
|
@foreach (var cred in restApiCredentials)
|
|
{
|
|
<option value="@cred.Name">@cred.Name (@cred.ServiceType - @cred.BaseUrl)</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrEmpty(selectedRestCredential))
|
|
{
|
|
<div class="mb-3">
|
|
<button class="btn btn-success btn-sm" @onclick="ConnectToRestApi" disabled="@isConnectingRest">
|
|
@if (isConnectingRest)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
|
}
|
|
<i class="fas fa-plug"></i> Connetti e Scopri Entità
|
|
</button>
|
|
@if (isRestConnected)
|
|
{
|
|
<span class="badge bg-success ms-2">Connesso</span>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@if (!string.IsNullOrEmpty(restErrorMessage))
|
|
{
|
|
<div class="alert alert-danger" role="alert">
|
|
@restErrorMessage
|
|
</div>
|
|
} <!-- Lista Entità REST -->
|
|
@if (restEntities.Any())
|
|
{
|
|
<div class="mb-3">
|
|
<h6>Entità REST (@restEntities.Count disponibili):</h6>
|
|
|
|
<!-- Campo di ricerca -->
|
|
<div class="mb-2">
|
|
<div class="input-group">
|
|
<span class="input-group-text">
|
|
<i class="fas fa-search"></i>
|
|
</span>
|
|
<input type="text" class="form-control" placeholder="Cerca entità..."
|
|
@bind="restSearchTerm" @oninput="FilterRestEntities" />
|
|
@if (!string.IsNullOrEmpty(restSearchTerm))
|
|
{
|
|
<button class="btn btn-outline-secondary" @onclick="ClearRestSearch">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lista entità filtrate -->
|
|
<div class="list-group" style="max-height: 300px; overflow-y: auto;">
|
|
@foreach (var entity in GetFilteredRestEntities())
|
|
{
|
|
<a class="list-group-item list-group-item-action @(selectedRestEntity?.Name == entity.Name ? "active" : "")"
|
|
@onclick="@(() => SelectRestEntity(entity))">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<i class="fas fa-cube"></i> @entity.Name
|
|
@if (!string.IsNullOrEmpty(entity.Label))
|
|
{
|
|
<small class="text-muted d-block">@entity.Label</small>
|
|
}
|
|
</div>
|
|
@if (selectedRestEntity?.Name == entity.Name)
|
|
{
|
|
<span class="badge bg-primary">Selezionata</span>
|
|
}
|
|
</div>
|
|
</a>
|
|
}
|
|
</div>
|
|
|
|
@if (!GetFilteredRestEntities().Any())
|
|
{
|
|
<div class="alert alert-info mt-2">
|
|
<i class="fas fa-info-circle"></i> Nessuna entità trovata con il termine di ricerca "@restSearchTerm"
|
|
</div>
|
|
} </div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div> <!-- Sezione Mapping (quando la fonte è selezionata e REST è connesso) -->
|
|
@{
|
|
var isSourceReady = (selectedSourceType == "database" && isDatabaseConnected &&
|
|
((useCustomQuery && isQueryValid) || (!useCustomQuery && !string.IsNullOrEmpty(selectedTable)))) ||
|
|
(selectedSourceType == "file" && !string.IsNullOrEmpty(selectedSheet));
|
|
}
|
|
@if (isSourceReady && isRestConnected && selectedRestEntity != null)
|
|
{
|
|
<div class="row mt-4">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header bg-success text-white">
|
|
<h5><i class="fas fa-exchange-alt"></i> Mapping Campi</h5>
|
|
</div> <div class="card-body">
|
|
@{
|
|
var sourceDisplayName = selectedSourceType == "database" ? selectedTable : selectedSheet;
|
|
var sourceTypeName = selectedSourceType == "database" ? "Tabella" : "Foglio";
|
|
}
|
|
<h6>Mapping tra @sourceTypeName @sourceDisplayName e @selectedRestEntity.Name</h6>
|
|
<p class="text-muted">Configura il mapping tra i campi della fonte dati e le proprietà dell'entità REST</p>
|
|
<div class="row">
|
|
<!-- Colonna Sinistra: Campi Fonte -->
|
|
<div class="col-5">
|
|
<h6>Campi @sourceTypeName (@sourceDisplayName)</h6>
|
|
@if (selectedSourceType == "database" && useCustomQuery && queryColumns.Any())
|
|
{
|
|
<!-- Le colonne della query validata sono disponibili per il mapping -->
|
|
}
|
|
<div class="list-group" style="max-height: 400px; overflow-y: auto;">
|
|
@if (selectedSourceType == "database")
|
|
{
|
|
@if (useCustomQuery && queryColumns.Any())
|
|
{
|
|
<!-- Colonne da query custom -->
|
|
@foreach (var column in queryColumns)
|
|
{
|
|
<a class="list-group-item list-group-item-action @(selectedDbColumn == column ? "active" : "")"
|
|
@onclick="@(() => SelectDbColumn(column))">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<strong>@column</strong>
|
|
<small class="text-muted d-block">Query Column</small>
|
|
</div>
|
|
<div>
|
|
@if (fieldMappings.ContainsKey(column))
|
|
{
|
|
<span class="badge bg-success">Mapped</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
}
|
|
}
|
|
else if (!useCustomQuery && databaseTables.ContainsKey(selectedTable))
|
|
{
|
|
<!-- Colonne da tabella -->
|
|
@foreach (var column in databaseTables[selectedTable])
|
|
{
|
|
<a class="list-group-item list-group-item-action @(selectedDbColumn == column.Name ? "active" : "")"
|
|
@onclick="@(() => SelectDbColumn(column.Name))">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<strong>@column.Name</strong>
|
|
<small class="text-muted d-block">@column.DataType</small>
|
|
</div>
|
|
<div>
|
|
@if (column.IsPrimaryKey)
|
|
{
|
|
<span class="badge bg-primary">PK</span>
|
|
}
|
|
@if (fieldMappings.ContainsKey(column.Name))
|
|
{
|
|
<span class="badge bg-success">Mapped</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
}
|
|
}
|
|
}
|
|
else if (selectedSourceType == "file" && fileSheets.ContainsKey(selectedSheet))
|
|
{
|
|
@foreach (var column in fileSheets[selectedSheet])
|
|
{
|
|
<a class="list-group-item list-group-item-action @(selectedDbColumn == column ? "active" : "")"
|
|
@onclick="@(() => SelectDbColumn(column))">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<strong>@column</strong>
|
|
<small class="text-muted d-block">Colonna File</small>
|
|
</div>
|
|
<div>
|
|
@if (fieldMappings.ContainsKey(column))
|
|
{
|
|
<span class="badge bg-success">Mapped</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
}
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Colonna Centrale: Controlli Mapping -->
|
|
<div class="col-2 text-center">
|
|
<div class="d-flex flex-column justify-content-center h-100">
|
|
<button class="btn btn-success mb-2" @onclick="CreateMapping"
|
|
disabled="@(string.IsNullOrEmpty(selectedDbColumn) || string.IsNullOrEmpty(selectedRestProperty))">
|
|
<i class="fas fa-arrow-right"></i>
|
|
<small class="d-block">Map</small>
|
|
</button>
|
|
<button class="btn btn-danger mb-2" @onclick="RemoveMapping"
|
|
disabled="@(string.IsNullOrEmpty(selectedDbColumn) || !fieldMappings.ContainsKey(selectedDbColumn))">
|
|
<i class="fas fa-times"></i>
|
|
<small class="d-block">Remove</small>
|
|
</button>
|
|
<button class="btn btn-warning mb-2" @onclick="AutoMapFields">
|
|
<i class="fas fa-magic"></i>
|
|
<small class="d-block">Auto</small>
|
|
</button>
|
|
<button class="btn btn-secondary" @onclick="ClearAllMappings">
|
|
<i class="fas fa-trash"></i>
|
|
<small class="d-block">Clear</small>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Colonna Destra: Proprietà REST -->
|
|
<div class="col-5">
|
|
<h6>Proprietà REST (@selectedRestEntity.Name)</h6>
|
|
<div class="list-group" style="max-height: 400px; overflow-y: auto;">
|
|
@if (restEntityDetails != null)
|
|
{
|
|
@foreach (var property in restEntityDetails.Properties)
|
|
{
|
|
<a class="list-group-item list-group-item-action @(selectedRestProperty == property.Name ? "active" : "")"
|
|
@onclick="@(() => SelectRestProperty(property.Name))">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<strong>@property.Name</strong>
|
|
<small class="text-muted d-block">@property.Type</small>
|
|
</div>
|
|
<div>
|
|
@if (property.IsRequired)
|
|
{
|
|
<span class="badge bg-danger">Required</span>
|
|
}
|
|
@if (fieldMappings.ContainsValue(property.Name))
|
|
{
|
|
<span class="badge bg-success">Mapped</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
}
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sezione Mappature Correnti --> @if (fieldMappings.Any())
|
|
{
|
|
<div class="mt-4">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<h6>Mappature Correnti (@fieldMappings.Count)</h6>
|
|
@if (keyFields.Any())
|
|
{
|
|
<small class="text-info">
|
|
<i class="fas fa-key"></i> @keyFields.Count campo/i chiave: @string.Join(", ", keyFields)
|
|
</small>
|
|
}
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>Campo Database</th>
|
|
<th>Tipo DB</th>
|
|
<th>→</th>
|
|
<th>Proprietà REST</th>
|
|
<th>Tipo REST</th>
|
|
<th>Azioni</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var mapping in fieldMappings)
|
|
{
|
|
DbColumnInfo? dbColumn = null;
|
|
if (selectedSourceType == "database" && !string.IsNullOrEmpty(selectedTable))
|
|
{
|
|
dbColumn = databaseTables.ContainsKey(selectedTable) ?
|
|
databaseTables[selectedTable].FirstOrDefault(c => c.Name == mapping.Key) : null;
|
|
}
|
|
var restProperty = restEntityDetails?.Properties.FirstOrDefault(p => p.Name == mapping.Value);
|
|
<tr>
|
|
<td><strong>@mapping.Key</strong></td>
|
|
<td><small class="text-muted">@(dbColumn?.DataType ?? (selectedSourceType == "file" ? "Text" : "Unknown"))</small></td>
|
|
<td><i class="fas fa-arrow-right text-success"></i></td>
|
|
<td><strong>@mapping.Value</strong></td>
|
|
<td><small class="text-muted">@(restProperty?.Type ?? "Unknown")</small></td>
|
|
<td>
|
|
<button class="btn btn-sm btn-danger" @onclick="@(() => RemoveSpecificMapping(mapping.Key))">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Configurazione Chiave Sorgente -->
|
|
@if (fieldMappings.Any())
|
|
{
|
|
<div class="mt-4">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h6 class="mb-0">
|
|
<i class="fas fa-key"></i> Configurazione Chiave Sorgente
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="form-check mb-3">
|
|
<input class="form-check-input" type="checkbox" id="useAssociations"
|
|
@bind="useRecordAssociations" />
|
|
<label class="form-check-label" for="useAssociations">
|
|
<strong>Utilizza sistema di associazioni basato sui valori delle chiavi</strong>
|
|
<br><small class="text-muted">Raccomandato: il sistema manterrà traccia delle associazioni tra valori chiave e record di destinazione, permettendo aggiornamenti indipendentemente dalla sorgente</small>
|
|
</label>
|
|
</div>
|
|
|
|
@if (useRecordAssociations)
|
|
{
|
|
<div class="alert alert-info">
|
|
<i class="fas fa-lightbulb"></i>
|
|
<strong>Come funziona il nuovo sistema:</strong>
|
|
<ul class="mb-0 mt-2">
|
|
<li>Ogni valore di chiave univoco viene associato a un record di destinazione</li>
|
|
<li>Più sorgenti diverse possono gestire lo stesso oggetto business usando lo stesso valore chiave</li>
|
|
<li>Gli aggiornamenti avvengono automaticamente quando si trova un'associazione esistente</li>
|
|
<li>Il sistema individua automaticamente le chiavi dove possibile, ma puoi sempre scegliere manualmente</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<label class="form-label">Campo Chiave Sorgente: <span class="text-danger">*</span></label>
|
|
<select class="form-select" @bind="sourceKeyField">
|
|
<option value="">-- Seleziona Campo Chiave --</option>
|
|
@if (!string.IsNullOrEmpty(suggestedPrimaryKey))
|
|
{
|
|
<option value="@suggestedPrimaryKey">@suggestedPrimaryKey (Primary Key - Consigliato)</option>
|
|
}
|
|
@if (selectedSourceType == "database")
|
|
{
|
|
@if (useCustomQuery && queryColumns.Any())
|
|
{
|
|
@foreach (var column in queryColumns)
|
|
{
|
|
<option value="@column">@column (Query Column)</option>
|
|
}
|
|
}
|
|
else if (!useCustomQuery && databaseTables.ContainsKey(selectedTable))
|
|
{
|
|
@foreach (var column in databaseTables[selectedTable].Where(c => c.Name != suggestedPrimaryKey))
|
|
{
|
|
<option value="@column.Name">@column.Name (@column.DataType)</option>
|
|
}
|
|
}
|
|
}
|
|
else if (selectedSourceType == "file" && fileSheets.ContainsKey(selectedSheet))
|
|
{
|
|
@foreach (var column in fileSheets[selectedSheet])
|
|
{
|
|
<option value="@column">@column</option>
|
|
}
|
|
}
|
|
</select>
|
|
@if (requiresManualKeySelection || selectedSourceType != "database")
|
|
{
|
|
<small class="form-text text-danger">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
Selezione del campo chiave obbligatoria. Scegli un campo che identifichi univocamente ogni record.
|
|
</small>
|
|
}
|
|
else if (!string.IsNullOrEmpty(suggestedPrimaryKey))
|
|
{
|
|
<small class="form-text text-success">
|
|
<i class="fas fa-key"></i>
|
|
Primary Key rilevata: <strong>@suggestedPrimaryKey</strong> (consigliato per l'identificazione univoca)
|
|
</small>
|
|
}
|
|
</div>
|
|
<div class="col-md-6">
|
|
@if (!string.IsNullOrEmpty(sourceKeyField))
|
|
{
|
|
<div class="mt-4">
|
|
<div class="alert alert-success">
|
|
<i class="fas fa-check-circle"></i>
|
|
<strong>Campo chiave selezionato:</strong> @sourceKeyField
|
|
<br><small>Questo campo verrà utilizzato per identificare univocamente i record sorgente</small>
|
|
@if (sourceKeyField == suggestedPrimaryKey)
|
|
{
|
|
<br><small class="text-success"><i class="fas fa-thumbs-up"></i> Ottima scelta! Stai usando la Primary Key della tabella.</small>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="mt-4">
|
|
<div class="alert alert-warning">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
<strong>Campo chiave richiesto</strong>
|
|
<br><small>Seleziona un campo che identifichi univocamente ogni record per abilitare il sistema di associazioni.</small>
|
|
@if (!string.IsNullOrEmpty(suggestedPrimaryKey))
|
|
{
|
|
<br><small class="text-info"><i class="fas fa-lightbulb"></i> Consiglio: seleziona <strong>@suggestedPrimaryKey</strong> (Primary Key rilevata)</small>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="alert alert-warning">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
<strong>Sistema associazioni disabilitato</strong><br>
|
|
Tutti i record verranno sempre inseriti come nuovi. Non sarà possibile tracciare aggiornamenti automatici.
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<div class="mt-3">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<button class="btn btn-success" @onclick="StartDataTransfer" disabled="@(!IsTransferButtonEnabled() || isTransferringData)"> @if (isTransferringData)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
|
<i class="fas fa-sync-alt"></i> @("Trasferimento in corso")
|
|
}
|
|
else
|
|
{
|
|
<i class="fas fa-play"></i> @("Avvia Trasferimento Dati")
|
|
}
|
|
</button>
|
|
|
|
@if (fieldMappings.Any())
|
|
{
|
|
<button class="btn btn-info ms-2" @onclick="ShowMappingSummary">
|
|
<i class="fas fa-list"></i> Riepilogo Mapping
|
|
</button>
|
|
}
|
|
</div> <div class="text-muted">
|
|
@if (fieldMappings.Any())
|
|
{
|
|
<small>
|
|
<i class="fas fa-arrow-right"></i> @fieldMappings.Count mapping(s) configurati<br/>
|
|
@if (useRecordAssociations)
|
|
{
|
|
<span><i class="fas fa-sync-alt text-info"></i> <strong>Modalità Smart Update</strong></span>
|
|
@if (!string.IsNullOrEmpty(sourceKeyField))
|
|
{
|
|
<span> (Chiave: @sourceKeyField)</span>
|
|
}
|
|
else
|
|
{
|
|
<span> (Rilevamento automatico)</span>
|
|
}
|
|
}
|
|
else
|
|
{
|
|
<span><i class="fas fa-plus text-success"></i> <strong>Modalità Insert Only</strong></span>
|
|
}
|
|
</small>
|
|
}
|
|
else
|
|
{
|
|
<small>
|
|
<i class="fas fa-exclamation-triangle"></i> Configura almeno una mappatura per iniziare
|
|
</small>
|
|
}
|
|
</div>
|
|
</div>
|
|
@if (!string.IsNullOrEmpty(transferMessage))
|
|
{
|
|
<div class="alert @(transferMessageType == "success" ? "alert-success" : transferMessageType == "warning" ? "alert-warning" : "alert-danger") mt-3" role="alert">
|
|
<i class="fas @(transferMessageType == "success" ? "fa-check-circle" : transferMessageType == "warning" ? "fa-exclamation-triangle" : "fa-exclamation-circle")"></i>
|
|
@transferMessage
|
|
</div>
|
|
}
|
|
|
|
@if (transferResults.Any())
|
|
{
|
|
<div class="mt-3">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<h6><i class="fas fa-list-alt"></i> Risultati Dettagliati Trasferimento (@transferResults.Count record)</h6>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
|
data-bs-toggle="collapse" data-bs-target="#transferResults"
|
|
aria-expanded="@showDetailedResults.ToString().ToLower()" aria-controls="transferResults"
|
|
@onclick="() => showDetailedResults = !showDetailedResults">
|
|
<i class="fas @(showDetailedResults ? "fa-chevron-up" : "fa-chevron-down")"></i>
|
|
@(showDetailedResults ? "Nascondi" : "Mostra") Dettagli
|
|
</button>
|
|
</div>
|
|
|
|
<div class="collapse @(showDetailedResults ? "show" : "")" id="transferResults">
|
|
<div class="card mt-2">
|
|
<div class="card-header">
|
|
<div class="row text-center">
|
|
<div class="col-3">
|
|
<small class="text-success"><i class="fas fa-check-circle"></i>
|
|
Inseriti: @transferResults.Count(r => r.Status == "success")</small>
|
|
</div>
|
|
<div class="col-3">
|
|
<small class="text-info"><i class="fas fa-edit"></i>
|
|
Aggiornati: @transferResults.Count(r => r.Status == "updated")</small>
|
|
</div>
|
|
<div class="col-3">
|
|
<small class="text-warning"><i class="fas fa-exclamation-triangle"></i>
|
|
Duplicati: @transferResults.Count(r => r.Status == "duplicate")</small>
|
|
</div>
|
|
<div class="col-3">
|
|
<small class="text-danger"><i class="fas fa-times-circle"></i>
|
|
Errori: @transferResults.Count(r => r.Status == "error")</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 10%;">#</th>
|
|
<th style="width: 15%;">Stato</th>
|
|
<th style="width: 20%;">ID Entità</th>
|
|
<th style="width: 55%;">Messaggio</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (var result in transferResults)
|
|
{
|
|
<tr class="@GetResultRowClass(result.Status)">
|
|
<td>@result.RecordNumber</td>
|
|
<td>
|
|
<span class="badge @GetResultBadgeClass(result.Status)">
|
|
<i class="fas @GetResultIcon(result.Status)"></i>
|
|
@GetResultStatusText(result.Status)
|
|
</span>
|
|
</td>
|
|
<td>
|
|
@if (!string.IsNullOrEmpty(result.EntityId))
|
|
{
|
|
<small class="text-muted">@result.EntityId</small>
|
|
}
|
|
else
|
|
{
|
|
<small class="text-muted">-</small>
|
|
}
|
|
</td>
|
|
<td>
|
|
<small>@result.Message</small>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<!-- Modal per la selezione del database -->
|
|
@if (showDatabaseSelectionModal)
|
|
{
|
|
<div class="modal fade show d-block" style="background-color: rgba(0,0,0,0.5);">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-database"></i> Seleziona Database
|
|
</h5>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="text-muted">
|
|
<i class="fas fa-info-circle"></i>
|
|
Il server non ha un database predefinito. Seleziona il database su cui eseguire le operazioni:
|
|
</p>
|
|
|
|
@if (availableDatabases != null && availableDatabases.Any())
|
|
{
|
|
<div class="mb-3">
|
|
<label for="databaseSelect" class="form-label">Database disponibili:</label>
|
|
<select id="databaseSelect" class="form-select" @bind="selectedDatabase">
|
|
<option value="">-- Seleziona un database --</option>
|
|
@foreach (var db in availableDatabases)
|
|
{
|
|
<option value="@db">@db</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="alert alert-warning">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
Nessun database trovato o errore nel caricamento.
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" @onclick="CancelDatabaseSelection">
|
|
<i class="fas fa-times"></i> Annulla
|
|
</button>
|
|
<button type="button" class="btn btn-primary" @onclick="OnDatabaseSelected"
|
|
disabled="@string.IsNullOrEmpty(selectedDatabase)">
|
|
<i class="fas fa-check"></i> Conferma
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@code {
|
|
// Classe per i risultati del trasferimento
|
|
public class TransferResult
|
|
{
|
|
public int RecordNumber { get; set; }
|
|
public string Status { get; set; } = ""; // "success", "error", "updated", "duplicate"
|
|
public string Message { get; set; } = "";
|
|
public string? EntityId { get; set; }
|
|
public Dictionary<string, object> RecordData { get; set; } = new();
|
|
}
|
|
|
|
// Stato delle credenziali
|
|
private List<DatabaseCredential> databaseCredentials = new();
|
|
private List<RestApiCredential> restApiCredentials = new();
|
|
|
|
// Selezione tipo fonte
|
|
private string selectedSourceType = "";
|
|
|
|
// Credenziali selezionate
|
|
private string selectedDatabaseCredential = "";
|
|
private string selectedRestCredential = "";
|
|
|
|
// Stato connessioni
|
|
private bool isConnectingDatabase = false;
|
|
private bool isConnectingRest = false;
|
|
private bool isDatabaseConnected = false;
|
|
private bool isRestConnected = false;
|
|
|
|
// Messaggi di errore
|
|
private string databaseErrorMessage = "";
|
|
private string restErrorMessage = "";
|
|
|
|
// Database discovery
|
|
private Dictionary<string, IEnumerable<DbColumnInfo>> databaseTables = new();
|
|
private string selectedTable = "";
|
|
private string databaseSearchTerm = "";
|
|
|
|
// Database selection
|
|
private List<string> availableDatabases = new();
|
|
private string selectedDatabase = "";
|
|
private bool showDatabaseSelection = false;
|
|
private bool showDatabaseSelectionModal = false;
|
|
private bool isLoadingDatabases = false;
|
|
|
|
// Custom query functionality
|
|
private bool useCustomQuery = false;
|
|
private string customQuery = "";
|
|
private bool isValidatingQuery = false;
|
|
private bool isQueryValid = false;
|
|
private string queryValidationMessage = "";
|
|
private List<Dictionary<string, object>> queryPreviewData = new();
|
|
private List<string> queryColumns = new();
|
|
private bool showQueryPreview = false;
|
|
private bool isLoadingPreview = false; // File handling
|
|
private string selectedFileName = "";
|
|
private bool isProcessingFile = false;
|
|
private string fileErrorMessage = "";
|
|
private Dictionary<string, IEnumerable<string>> fileSheets = new(); // SheetName -> Columns
|
|
private Dictionary<string, List<Dictionary<string, object>>> fileData = new(); // SheetName -> Data rows
|
|
private string selectedSheet = "";
|
|
|
|
// File preview pagination
|
|
private int currentPage = 1;
|
|
private int pageSize = 20;
|
|
private int GetTotalPages(string sheetName) => fileData.ContainsKey(sheetName) ?
|
|
(int)Math.Ceiling((double)fileData[sheetName].Count / pageSize) : 0;
|
|
|
|
// REST discovery
|
|
private List<RestEntitySummary> restEntities = new();
|
|
private RestEntitySummary? selectedRestEntity = null;
|
|
private RestEntityInfo? restEntityDetails = null;
|
|
private string restSearchTerm = "";
|
|
// Mapping campi
|
|
private Dictionary<string, string> fieldMappings = new(); // DbColumn -> RestProperty
|
|
private HashSet<string> keyFields = new(); // REST properties marked as keys
|
|
private string selectedDbColumn = "";
|
|
private string selectedRestProperty = "";
|
|
|
|
// Gestione chiavi sorgente e associazioni
|
|
private string sourceKeyField = ""; // Campo che identifica univocamente il record sorgente
|
|
private string suggestedPrimaryKey = ""; // Campo PK suggerito per database
|
|
private bool requiresManualKeySelection = false; // Flag per indicare se è richiesta selezione manuale
|
|
private Dictionary<string, string> sourceKeyMappings = new(); // Per CSV: mapppatura colonna -> nome campo chiave
|
|
private bool useRecordAssociations = true; // Se utilizzare il sistema di associazioni
|
|
|
|
// Trasferimento dati
|
|
private bool isTransferringData = false;
|
|
private string transferMessage = "";
|
|
private string transferMessageType = "";
|
|
private List<TransferResult> transferResults = new();
|
|
private bool showDetailedResults = false;
|
|
|
|
// Servizi
|
|
private IDatabaseManager? currentDatabaseManager = null;
|
|
private IRestMetadataDiscovery? currentRestDiscovery = null;
|
|
private IRestServiceClient? currentRestClient = null;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await LoadCredentials();
|
|
} private async Task LoadCredentials()
|
|
{
|
|
try
|
|
{
|
|
databaseCredentials = await CredentialService.GetAllDatabaseCredentialsAsync();
|
|
restApiCredentials = await CredentialService.GetAllRestApiCredentialsAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nel caricamento delle credenziali");
|
|
await JSRuntime.InvokeVoidAsync("alert", $"Errore nel caricamento delle credenziali: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private void OnSourceTypeChanged(ChangeEventArgs e)
|
|
{
|
|
selectedSourceType = e.Value?.ToString() ?? "";
|
|
|
|
// Reset state when changing source type
|
|
ResetSourceState(); } private void ResetSourceState()
|
|
{
|
|
// Reset database state
|
|
ResetDatabaseState();
|
|
|
|
// Reset file state
|
|
selectedFileName = "";
|
|
isProcessingFile = false;
|
|
fileErrorMessage = "";
|
|
fileSheets.Clear();
|
|
fileData.Clear();
|
|
selectedSheet = "";
|
|
|
|
// Reset pagination
|
|
currentPage = 1;
|
|
|
|
// Reset mappings
|
|
ClearAllMappings();
|
|
}
|
|
|
|
private async Task OnFileSelected(InputFileChangeEventArgs e)
|
|
{ try
|
|
{
|
|
isProcessingFile = true;
|
|
fileErrorMessage = "";
|
|
fileSheets.Clear();
|
|
fileData.Clear();
|
|
selectedSheet = "";
|
|
|
|
var file = e.File;
|
|
selectedFileName = file.Name;
|
|
|
|
// Validate file type
|
|
var extension = Path.GetExtension(file.Name).ToLowerInvariant();
|
|
if (extension != ".xlsx" && extension != ".xls" && extension != ".csv")
|
|
{
|
|
fileErrorMessage = "Formato file non supportato. Utilizzare Excel (.xlsx, .xls) o CSV (.csv)";
|
|
return;
|
|
}
|
|
|
|
// Process file based on type
|
|
if (extension == ".csv")
|
|
{
|
|
await ProcessCsvFile(file);
|
|
}
|
|
else
|
|
{
|
|
await ProcessExcelFile(file);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nell'elaborazione del file");
|
|
fileErrorMessage = $"Errore nell'elaborazione del file: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
isProcessingFile = false;
|
|
StateHasChanged();
|
|
}
|
|
} private async Task ProcessCsvFile(IBrowserFile file)
|
|
{
|
|
using var stream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024); // Aumentato a 50MB
|
|
using var reader = new StreamReader(stream);
|
|
|
|
var firstLine = await reader.ReadLineAsync();
|
|
if (string.IsNullOrEmpty(firstLine))
|
|
{
|
|
fileErrorMessage = "Il file CSV è vuoto";
|
|
return;
|
|
}
|
|
|
|
Logger.LogInformation("CSV first line: {FirstLine}", firstLine);
|
|
|
|
// Detect separator automatically
|
|
var separator = DetectCsvSeparator(firstLine);
|
|
Logger.LogInformation("CSV separator detected: '{Separator}'", separator);
|
|
|
|
// Parse headers (first row) - gestisce meglio i separatori
|
|
var headers = ParseCsvLine(firstLine, separator);
|
|
|
|
Logger.LogInformation("CSV headers parsed: {Headers}", string.Join(" | ", headers));
|
|
// For CSV, we create a single "sheet" with the filename
|
|
var sheetName = Path.GetFileNameWithoutExtension(file.Name);
|
|
fileSheets[sheetName] = headers;
|
|
|
|
// Read data rows - rimuovo il limite di 1000 righe
|
|
var dataRows = new List<Dictionary<string, object>>();
|
|
string? line;
|
|
int rowNumber = 2; // Starting from row 2 (after header)
|
|
|
|
while ((line = await reader.ReadLineAsync()) != null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
|
|
|
var values = ParseCsvLine(line, separator);
|
|
var row = new Dictionary<string, object>();
|
|
for (int i = 0; i < headers.Count; i++)
|
|
{
|
|
var value = i < values.Count ? values[i] : "";
|
|
row[headers[i]] = string.IsNullOrEmpty(value) ? "" : value;
|
|
}
|
|
|
|
dataRows.Add(row);
|
|
rowNumber++;
|
|
|
|
// Log delle prime 3 righe per debug
|
|
if (rowNumber <= 5)
|
|
{
|
|
Logger.LogInformation("CSV row {RowNumber}: {Values}", rowNumber - 1, string.Join(" | ", values));
|
|
}
|
|
}
|
|
fileData[sheetName] = dataRows;
|
|
|
|
// Auto-seleziona il foglio per i CSV dato che ce n'è solo uno
|
|
selectedSheet = sheetName;
|
|
|
|
Logger.LogInformation("CSV file processed: {FileName}, Headers: {HeaderCount} ({Headers}), Rows: {RowCount}, Auto-selected sheet: {SheetName}",
|
|
file.Name, headers.Count, string.Join(", ", headers), dataRows.Count, selectedSheet);
|
|
} private List<string> ParseCsvLine(string line, char separator = ',')
|
|
{
|
|
var result = new List<string>();
|
|
var current = new StringBuilder();
|
|
bool inQuotes = false;
|
|
|
|
for (int i = 0; i < line.Length; i++)
|
|
{
|
|
char c = line[i];
|
|
|
|
if (c == '"')
|
|
{
|
|
if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
|
|
{
|
|
// Double quote - escaped quote
|
|
current.Append('"');
|
|
i++; // Skip next quote
|
|
}
|
|
else
|
|
{
|
|
// Toggle quote mode
|
|
inQuotes = !inQuotes;
|
|
}
|
|
}
|
|
else if (c == separator && !inQuotes)
|
|
{
|
|
// End of field
|
|
result.Add(current.ToString().Trim());
|
|
current.Clear();
|
|
}
|
|
else
|
|
{
|
|
current.Append(c);
|
|
}
|
|
}
|
|
|
|
// Add the last field
|
|
result.Add(current.ToString().Trim());
|
|
|
|
return result;
|
|
}private async Task ProcessExcelFile(IBrowserFile file)
|
|
{
|
|
try
|
|
{
|
|
using var stream = file.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024); // 50MB max
|
|
|
|
// Crea il reader Excel basato sull'estensione
|
|
IExcelDataReader reader;
|
|
var extension = Path.GetExtension(file.Name).ToLowerInvariant();
|
|
|
|
if (extension == ".xlsx")
|
|
{
|
|
reader = ExcelReaderFactory.CreateOpenXmlReader(stream);
|
|
}
|
|
else if (extension == ".xls")
|
|
{
|
|
reader = ExcelReaderFactory.CreateBinaryReader(stream);
|
|
}
|
|
else
|
|
{
|
|
fileErrorMessage = "Formato Excel non supportato. Utilizzare .xlsx o .xls";
|
|
return;
|
|
}
|
|
|
|
using (reader)
|
|
{
|
|
// Configura per utilizzare la prima riga come header
|
|
var configuration = new ExcelDataSetConfiguration()
|
|
{
|
|
ConfigureDataTable = (_) => new ExcelDataTableConfiguration()
|
|
{
|
|
UseHeaderRow = true // Prima riga come header
|
|
}
|
|
};
|
|
|
|
// Converti in DataSet
|
|
var dataSet = reader.AsDataSet(configuration);
|
|
|
|
Logger.LogInformation("Excel file processed: {FileName}, Sheets: {SheetCount}",
|
|
file.Name, dataSet.Tables.Count);
|
|
|
|
// Processa ogni foglio
|
|
foreach (DataTable table in dataSet.Tables)
|
|
{
|
|
var sheetName = table.TableName;
|
|
var headers = new List<string>();
|
|
var dataRows = new List<Dictionary<string, object>>();
|
|
|
|
// Estrai i nomi delle colonne (headers)
|
|
foreach (DataColumn column in table.Columns)
|
|
{
|
|
headers.Add(column.ColumnName);
|
|
}
|
|
|
|
Logger.LogInformation("Processing Excel sheet: {SheetName}, Columns: {ColumnCount}, Rows: {RowCount}",
|
|
sheetName, headers.Count, table.Rows.Count);
|
|
|
|
// Processa le righe di dati
|
|
for (int i = 0; i < table.Rows.Count; i++)
|
|
{
|
|
var row = table.Rows[i];
|
|
var rowData = new Dictionary<string, object>();
|
|
|
|
for (int j = 0; j < headers.Count; j++)
|
|
{
|
|
var cellValue = row[j]?.ToString() ?? "";
|
|
rowData[headers[j]] = string.IsNullOrEmpty(cellValue) ? "" : cellValue;
|
|
}
|
|
|
|
dataRows.Add(rowData);
|
|
|
|
// Log delle prime 3 righe per debug
|
|
if (i < 3)
|
|
{
|
|
Logger.LogInformation("Excel row {RowNumber} in {Sheet}: {Values}",
|
|
i + 1, sheetName, string.Join(" | ", rowData.Values));
|
|
}
|
|
}
|
|
|
|
// Salva i dati del foglio
|
|
fileSheets[sheetName] = headers;
|
|
fileData[sheetName] = dataRows;
|
|
|
|
Logger.LogInformation("Excel sheet completed: {SheetName}, Headers: {Headers}, Rows: {RowCount}",
|
|
sheetName, string.Join(", ", headers), dataRows.Count);
|
|
}
|
|
|
|
// Auto-seleziona il primo foglio se non c'è una selezione
|
|
if (fileSheets.Any() && string.IsNullOrEmpty(selectedSheet))
|
|
{
|
|
selectedSheet = fileSheets.First().Key;
|
|
Logger.LogInformation("Auto-selected first sheet: {SheetName}", selectedSheet);
|
|
} Logger.LogInformation("Excel file processing completed: {FileName}, Total sheets: {SheetCount}, Selected: {SelectedSheet}",
|
|
file.Name, fileSheets.Count, selectedSheet);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nell'elaborazione del file Excel: {FileName}", file.Name);
|
|
fileErrorMessage = $"Errore nell'elaborazione del file Excel: {ex.Message}";
|
|
}
|
|
|
|
await Task.CompletedTask;
|
|
} private void SelectSheet(string sheetName)
|
|
{
|
|
selectedSheet = sheetName;
|
|
|
|
// Reset pagination when changing sheet
|
|
currentPage = 1;
|
|
|
|
// Clear mappings when changing sheet
|
|
ClearAllMappings();
|
|
|
|
// For file sources, always require manual key selection
|
|
sourceKeyField = "";
|
|
suggestedPrimaryKey = "";
|
|
requiresManualKeySelection = true;
|
|
|
|
StateHasChanged();
|
|
}
|
|
|
|
// File preview pagination methods
|
|
private void GoToPage(int page)
|
|
{
|
|
if (string.IsNullOrEmpty(selectedSheet) || !fileData.ContainsKey(selectedSheet))
|
|
return;
|
|
|
|
var totalPages = GetTotalPages(selectedSheet);
|
|
if (page >= 1 && page <= totalPages)
|
|
{
|
|
currentPage = page;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private void FirstPage() => GoToPage(1);
|
|
private void PreviousPage() => GoToPage(currentPage - 1);
|
|
private void NextPage() => GoToPage(currentPage + 1);
|
|
private void LastPage() => GoToPage(GetTotalPages(selectedSheet));
|
|
|
|
private List<Dictionary<string, object>> GetCurrentPageData()
|
|
{
|
|
if (string.IsNullOrEmpty(selectedSheet) || !fileData.ContainsKey(selectedSheet))
|
|
return new List<Dictionary<string, object>>();
|
|
|
|
var allData = fileData[selectedSheet];
|
|
var skip = (currentPage - 1) * pageSize;
|
|
return allData.Skip(skip).Take(pageSize).ToList();
|
|
}
|
|
|
|
private int GetStartRecord()
|
|
{
|
|
if (string.IsNullOrEmpty(selectedSheet) || !fileData.ContainsKey(selectedSheet))
|
|
return 0;
|
|
return (currentPage - 1) * pageSize + 1;
|
|
} private int GetEndRecord()
|
|
{
|
|
if (string.IsNullOrEmpty(selectedSheet) || !fileData.ContainsKey(selectedSheet))
|
|
return 0;
|
|
var totalRecords = fileData[selectedSheet].Count;
|
|
var endRecord = currentPage * pageSize;
|
|
return Math.Min(endRecord, totalRecords);
|
|
}
|
|
|
|
private void OnPageSizeChanged(ChangeEventArgs e)
|
|
{
|
|
if (int.TryParse(e.Value?.ToString(), out int newPageSize))
|
|
{
|
|
pageSize = newPageSize;
|
|
currentPage = 1; // Reset to first page when changing page size
|
|
StateHasChanged();
|
|
}
|
|
}private void OnDatabaseCredentialChanged(ChangeEventArgs e)
|
|
{
|
|
selectedDatabaseCredential = e.Value?.ToString() ?? "";
|
|
ResetDatabaseState();
|
|
} private void OnRestCredentialChanged(ChangeEventArgs e)
|
|
{
|
|
var newCredential = e.Value?.ToString() ?? "";
|
|
|
|
// Clear the cache if we're switching to a different credential
|
|
if (!string.IsNullOrEmpty(selectedRestCredential) && selectedRestCredential != newCredential)
|
|
{
|
|
ConnectionFactory.ClearRestClientCache(selectedRestCredential);
|
|
Logger.LogInformation("Cleared REST client cache for credential: {CredentialName}", selectedRestCredential);
|
|
}
|
|
|
|
selectedRestCredential = newCredential;
|
|
ResetRestState();
|
|
} private void ResetDatabaseState()
|
|
{
|
|
isDatabaseConnected = false;
|
|
databaseTables.Clear();
|
|
selectedTable = "";
|
|
databaseSearchTerm = "";
|
|
databaseErrorMessage = "";
|
|
|
|
// Reset custom query state
|
|
useCustomQuery = false;
|
|
customQuery = "";
|
|
isValidatingQuery = false;
|
|
isQueryValid = false;
|
|
queryValidationMessage = "";
|
|
queryPreviewData.Clear();
|
|
queryColumns.Clear();
|
|
showQueryPreview = false;
|
|
isLoadingPreview = false;
|
|
|
|
currentDatabaseManager?.Dispose();
|
|
currentDatabaseManager = null;
|
|
|
|
// Clear mappings when resetting database state
|
|
ClearAllMappings();
|
|
} private void ResetRestState()
|
|
{
|
|
isRestConnected = false;
|
|
restEntities.Clear();
|
|
selectedRestEntity = null;
|
|
restEntityDetails = null;
|
|
restSearchTerm = "";
|
|
restErrorMessage = "";
|
|
currentRestDiscovery = null;
|
|
currentRestClient = null;
|
|
|
|
// Clear mappings when resetting REST state
|
|
ClearAllMappings();
|
|
}private async Task ConnectToDatabase()
|
|
{
|
|
if (string.IsNullOrEmpty(selectedDatabaseCredential))
|
|
return;
|
|
|
|
isConnectingDatabase = true;
|
|
databaseErrorMessage = "";
|
|
|
|
try
|
|
{ // Trova la credenziale
|
|
var credential = databaseCredentials.FirstOrDefault(c => c.Name == selectedDatabaseCredential);
|
|
if (credential == null)
|
|
{
|
|
databaseErrorMessage = "Credenziale database non trovata";
|
|
return;
|
|
}
|
|
|
|
// Test della connessione
|
|
var (success, message) = await CredentialService.TestDatabaseConnectionAsync(credential.Name);
|
|
if (!success)
|
|
{
|
|
databaseErrorMessage = $"Connessione fallita: {message}";
|
|
return;
|
|
} // Crea il database manager usando il factory con le credenziali complete
|
|
Logger.LogInformation("Creando database manager per credenziale: {CredentialName}", selectedDatabaseCredential);
|
|
currentDatabaseManager = await ConnectionFactory.CreateDatabaseManagerAsync(selectedDatabaseCredential);
|
|
Logger.LogInformation("Database manager creato con successo");
|
|
|
|
Logger.LogInformation("Iniziando discovery dello schema per database {DatabaseType} con credenziale: {CredentialName}", credential.DatabaseType, selectedDatabaseCredential);
|
|
|
|
// Discovery dello schema con try-catch specifico
|
|
try
|
|
{
|
|
var schema = await currentDatabaseManager.GetDatabaseSchemaAsync();
|
|
|
|
Logger.LogInformation("Schema discovery completato. Tipo restituito: {SchemaType}, Numero elementi: {Count}",
|
|
schema?.GetType().Name ?? "null",
|
|
schema?.Count() ?? 0);
|
|
|
|
databaseTables = schema as Dictionary<string, IEnumerable<DbColumnInfo>> ??
|
|
(schema != null ? new Dictionary<string, IEnumerable<DbColumnInfo>>(schema) : new Dictionary<string, IEnumerable<DbColumnInfo>>());
|
|
|
|
Logger.LogInformation("Database tables dopo conversione: {Count} tabelle", databaseTables.Count);
|
|
|
|
if (databaseTables.Count == 0)
|
|
{
|
|
// Se non ci sono tabelle, potrebbe essere perché non è stato selezionato un database specifico
|
|
await HandleDatabaseSelectionRequired();
|
|
return;
|
|
}
|
|
}
|
|
catch (Exception schemaEx)
|
|
{
|
|
Logger.LogError(schemaEx, "Errore specifico durante lo schema discovery");
|
|
databaseErrorMessage = $"Errore nello schema discovery: {schemaEx.Message}";
|
|
throw;
|
|
}
|
|
|
|
isDatabaseConnected = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nella connessione al database");
|
|
databaseErrorMessage = $"Errore: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
isConnectingDatabase = false;
|
|
}
|
|
} private async Task ConnectToRestApi()
|
|
{
|
|
if (string.IsNullOrEmpty(selectedRestCredential))
|
|
return;
|
|
|
|
isConnectingRest = true;
|
|
restErrorMessage = "";
|
|
|
|
try
|
|
{
|
|
// Trova la credenziale
|
|
var credential = restApiCredentials.FirstOrDefault(c => c.Name == selectedRestCredential);
|
|
if (credential == null)
|
|
{
|
|
restErrorMessage = "Credenziale REST API non trovata";
|
|
return;
|
|
}
|
|
|
|
// Test della connessione
|
|
var (success, message) = await CredentialService.TestRestApiConnectionAsync(credential.Name);
|
|
if (!success)
|
|
{
|
|
restErrorMessage = $"Connessione fallita: {message}";
|
|
return;
|
|
} // Crea i client REST usando il factory con le credenziali complete
|
|
currentRestClient = await ConnectionFactory.CreateRestServiceClientAsync(selectedRestCredential);
|
|
currentRestDiscovery = await ConnectionFactory.CreateRestMetadataDiscoveryAsync(selectedRestCredential); Logger.LogInformation("Iniziando autenticazione per il servizio REST {ServiceType} con credenziale: {CredentialName}", credential.ServiceType, selectedRestCredential);
|
|
|
|
// Autenticazione prima del discovery
|
|
var authResult = await currentRestClient.AuthenticateAsync();
|
|
if (!authResult)
|
|
{
|
|
Logger.LogWarning("Autenticazione fallita per il servizio REST {ServiceType}", credential.ServiceType);
|
|
restErrorMessage = "Autenticazione fallita per il servizio REST";
|
|
return;
|
|
}
|
|
|
|
Logger.LogInformation("Autenticazione completata. Iniziando discovery delle entità REST per {ServiceType}", credential.ServiceType);
|
|
|
|
// Discovery delle entità disponibili
|
|
restEntities = await currentRestDiscovery.DiscoverEntitySummariesAsync();
|
|
|
|
Logger.LogInformation("Discovery completato. Trovate {Count} entità", restEntities?.Count ?? 0);
|
|
|
|
if (restEntities == null || !restEntities.Any())
|
|
{
|
|
Logger.LogWarning("Nessuna entità trovata dal servizio REST");
|
|
restErrorMessage = "Nessuna entità disponibile dal servizio REST";
|
|
return;
|
|
}
|
|
|
|
isRestConnected = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nella connessione al servizio REST");
|
|
restErrorMessage = $"Errore: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
isConnectingRest = false;
|
|
}
|
|
} private async void SelectTable(string tableName)
|
|
{
|
|
selectedTable = tableName;
|
|
|
|
// Clear custom query state when selecting a table
|
|
useCustomQuery = false;
|
|
customQuery = "";
|
|
isQueryValid = false;
|
|
queryValidationMessage = "";
|
|
queryPreviewData.Clear();
|
|
queryColumns.Clear();
|
|
showQueryPreview = false;
|
|
|
|
// Clear mappings when changing table
|
|
ClearAllMappings();
|
|
|
|
// Reset key field logic
|
|
sourceKeyField = "";
|
|
suggestedPrimaryKey = "";
|
|
requiresManualKeySelection = false;
|
|
|
|
// If it's a database source, try to detect the primary key
|
|
if (selectedSourceType == "database" && currentDatabaseManager != null)
|
|
{
|
|
try
|
|
{
|
|
var primaryKey = await currentDatabaseManager.GetPrimaryKeyFieldAsync(tableName);
|
|
if (!string.IsNullOrEmpty(primaryKey))
|
|
{
|
|
suggestedPrimaryKey = primaryKey;
|
|
// Suggest the primary key but don't auto-select it
|
|
Logger.LogInformation("Primary key detected for table {TableName}: {PrimaryKey}", tableName, primaryKey);
|
|
}
|
|
else
|
|
{
|
|
// No primary key found, require manual selection
|
|
requiresManualKeySelection = true;
|
|
Logger.LogInformation("No primary key found for table {TableName}, manual selection required", tableName);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Error detecting primary key for table {TableName}", tableName);
|
|
requiresManualKeySelection = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// For non-database sources, always require manual selection
|
|
requiresManualKeySelection = true;
|
|
}
|
|
|
|
StateHasChanged();
|
|
} private async Task SelectRestEntity(RestEntitySummary entity)
|
|
{
|
|
selectedRestEntity = entity;
|
|
|
|
// Clear mappings when changing entity
|
|
ClearAllMappings();
|
|
|
|
try
|
|
{
|
|
if (currentRestDiscovery != null)
|
|
{
|
|
// Discovery dei dettagli dell'entità
|
|
restEntityDetails = await currentRestDiscovery.DiscoverEntityDetailsAsync(entity.Name); }
|
|
else
|
|
{
|
|
restErrorMessage = "Servizio di discovery REST non disponibile";
|
|
return;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nel caricamento dettagli entità {EntityName}", entity.Name);
|
|
restErrorMessage = $"Errore nel caricamento dettagli entità: {ex.Message}";
|
|
} }
|
|
|
|
// Metodi per la ricerca e il filtraggio
|
|
private IEnumerable<string> GetFilteredDatabaseTables()
|
|
{
|
|
if (string.IsNullOrEmpty(databaseSearchTerm))
|
|
return databaseTables.Keys;
|
|
|
|
return databaseTables.Keys.Where(table =>
|
|
table.Contains(databaseSearchTerm, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private IEnumerable<RestEntitySummary> GetFilteredRestEntities()
|
|
{
|
|
if (string.IsNullOrEmpty(restSearchTerm))
|
|
return restEntities;
|
|
|
|
return restEntities.Where(entity =>
|
|
entity.Name.Contains(restSearchTerm, StringComparison.OrdinalIgnoreCase) ||
|
|
(!string.IsNullOrEmpty(entity.Label) && entity.Label.Contains(restSearchTerm, StringComparison.OrdinalIgnoreCase)));
|
|
}
|
|
|
|
private async Task FilterDatabaseTables(ChangeEventArgs e)
|
|
{
|
|
databaseSearchTerm = e.Value?.ToString() ?? "";
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
private async Task FilterRestEntities(ChangeEventArgs e)
|
|
{
|
|
restSearchTerm = e.Value?.ToString() ?? "";
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
private async Task ClearDatabaseSearch()
|
|
{
|
|
databaseSearchTerm = "";
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
private async Task ClearRestSearch()
|
|
{
|
|
restSearchTerm = "";
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
// Metodi per il mapping dei campi
|
|
private void SelectDbColumn(string columnName)
|
|
{
|
|
selectedDbColumn = columnName;
|
|
}
|
|
|
|
private void SelectRestProperty(string propertyName)
|
|
{
|
|
selectedRestProperty = propertyName;
|
|
}
|
|
|
|
private void CreateMapping()
|
|
{
|
|
if (string.IsNullOrEmpty(selectedDbColumn) || string.IsNullOrEmpty(selectedRestProperty))
|
|
return;
|
|
|
|
// Rimuovi eventuali mapping esistenti per questo campo database
|
|
if (fieldMappings.ContainsKey(selectedDbColumn))
|
|
{
|
|
fieldMappings.Remove(selectedDbColumn);
|
|
}
|
|
|
|
// Crea il nuovo mapping
|
|
fieldMappings[selectedDbColumn] = selectedRestProperty;
|
|
|
|
Logger.LogInformation("Creato mapping: {DbColumn} -> {RestProperty}", selectedDbColumn, selectedRestProperty);
|
|
|
|
// Deseleziona i campi
|
|
selectedDbColumn = "";
|
|
selectedRestProperty = "";
|
|
}
|
|
|
|
private void RemoveMapping()
|
|
{
|
|
if (string.IsNullOrEmpty(selectedDbColumn) || !fieldMappings.ContainsKey(selectedDbColumn))
|
|
return;
|
|
|
|
fieldMappings.Remove(selectedDbColumn);
|
|
Logger.LogInformation("Rimosso mapping per campo: {DbColumn}", selectedDbColumn);
|
|
} private void RemoveSpecificMapping(string dbColumn)
|
|
{
|
|
if (fieldMappings.ContainsKey(dbColumn))
|
|
{
|
|
fieldMappings.Remove(dbColumn);
|
|
Logger.LogInformation("Rimosso mapping specifico per campo: {DbColumn}", dbColumn);
|
|
}
|
|
}
|
|
|
|
private void ClearAllMappings()
|
|
{
|
|
fieldMappings.Clear();
|
|
selectedDbColumn = "";
|
|
selectedRestProperty = "";
|
|
sourceKeyField = "";
|
|
transferMessage = "";
|
|
transferMessageType = "";
|
|
Logger.LogInformation("Tutti i mapping e le configurazioni sono stati cancellati");
|
|
}
|
|
|
|
private void AutoMapFields()
|
|
{
|
|
if (restEntityDetails == null)
|
|
return;
|
|
|
|
IEnumerable<string> sourceColumns = new List<string>();
|
|
|
|
// Ottiene le colonne in base al tipo di sorgente
|
|
if (selectedSourceType == "database")
|
|
{
|
|
if (useCustomQuery && queryColumns.Any())
|
|
{
|
|
sourceColumns = queryColumns;
|
|
}
|
|
else if (!useCustomQuery && databaseTables.ContainsKey(selectedTable))
|
|
{
|
|
sourceColumns = databaseTables[selectedTable].Select(c => c.Name);
|
|
}
|
|
}
|
|
else if (selectedSourceType == "file" && fileSheets.ContainsKey(selectedSheet))
|
|
{
|
|
sourceColumns = fileSheets[selectedSheet];
|
|
}
|
|
|
|
if (!sourceColumns.Any())
|
|
return;
|
|
|
|
var restProperties = restEntityDetails.Properties;
|
|
int mappingsCreated = 0;
|
|
|
|
foreach (var sourceColumn in sourceColumns)
|
|
{
|
|
// Trova una proprietà REST con nome simile
|
|
var matchingProperty = restProperties.FirstOrDefault(p =>
|
|
string.Equals(p.Name, sourceColumn, StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(p.Name.Replace("_", ""), sourceColumn.Replace("_", ""), StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(p.Name.Replace("Id", ""), sourceColumn.Replace("Id", ""), StringComparison.OrdinalIgnoreCase)
|
|
);
|
|
|
|
if (matchingProperty != null && !fieldMappings.ContainsKey(sourceColumn))
|
|
{
|
|
fieldMappings[sourceColumn] = matchingProperty.Name;
|
|
mappingsCreated++;
|
|
}
|
|
}
|
|
|
|
Logger.LogInformation("Auto-mapping completato. Creati {Count} mapping automatici da {SourceType}",
|
|
mappingsCreated, useCustomQuery ? "query custom" : selectedSourceType);
|
|
} private async Task ShowMappingSummary()
|
|
{
|
|
var summary = "Riepilogo Configurazione:\n\n";
|
|
summary += "=== MAPPING CAMPI ===\n";
|
|
foreach (var mapping in fieldMappings)
|
|
{
|
|
summary += $"• {mapping.Key} → {mapping.Value}\n";
|
|
}
|
|
|
|
summary += "\n=== CONFIGURAZIONE ASSOCIAZIONI ===\n";
|
|
summary += $"• Sistema associazioni: {(useRecordAssociations ? "Abilitato" : "Disabilitato")}\n";
|
|
if (useRecordAssociations)
|
|
{
|
|
summary += $"• Campo chiave sorgente: {(!string.IsNullOrEmpty(sourceKeyField) ? sourceKeyField : "Rilevamento automatico")}\n";
|
|
}
|
|
|
|
await JSRuntime.InvokeVoidAsync("alert", summary);
|
|
} private async Task StartDataTransfer()
|
|
{
|
|
if (!fieldMappings.Any() || currentRestClient == null || selectedRestEntity == null)
|
|
{
|
|
transferMessage = "Configurazione incompleta. Assicurati di aver selezionato la fonte dati, entità e configurato almeno una mappatura.";
|
|
transferMessageType = "error";
|
|
return;
|
|
}
|
|
|
|
// Check source-specific requirements
|
|
if (selectedSourceType == "database")
|
|
{
|
|
if (currentDatabaseManager == null)
|
|
{
|
|
transferMessage = "Database non connesso.";
|
|
transferMessageType = "error";
|
|
return;
|
|
}
|
|
|
|
if (useCustomQuery)
|
|
{
|
|
if (!isQueryValid || string.IsNullOrWhiteSpace(customQuery))
|
|
{
|
|
transferMessage = "Query custom non valida. Validare la query prima di procedere.";
|
|
transferMessageType = "error";
|
|
return;
|
|
}
|
|
}
|
|
else if (string.IsNullOrEmpty(selectedTable))
|
|
{
|
|
transferMessage = "Tabella non selezionata.";
|
|
transferMessageType = "error";
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (selectedSourceType == "file" && string.IsNullOrEmpty(selectedSheet))
|
|
{
|
|
transferMessage = "File non caricato o foglio non selezionato.";
|
|
transferMessageType = "error";
|
|
return;
|
|
}
|
|
|
|
// Validate source key field when using record associations
|
|
if (useRecordAssociations && string.IsNullOrEmpty(sourceKeyField))
|
|
{
|
|
transferMessage = "Campo chiave sorgente richiesto. Seleziona un campo che identifichi univocamente ogni record per utilizzare il sistema di associazioni.";
|
|
transferMessageType = "error";
|
|
return;
|
|
}
|
|
|
|
isTransferringData = true;
|
|
transferMessage = "";
|
|
transferMessageType = "";
|
|
transferResults.Clear();
|
|
|
|
try
|
|
{
|
|
var sourceName = selectedSourceType == "database"
|
|
? (useCustomQuery ? "custom_query" : selectedTable)
|
|
: selectedSheet;
|
|
Logger.LogInformation("Iniziando trasferimento dati da {SourceType} {Source} a {Entity} con {MappingCount} mappature",
|
|
selectedSourceType, sourceName, selectedRestEntity.Name, fieldMappings.Count);
|
|
|
|
// 1. Ottieni tutti i record dalla fonte dati
|
|
var records = await GetAllRecordsFromSource();
|
|
Logger.LogInformation("Ottenuti {RecordCount} record da {SourceType} {Source}", records.Count(), selectedSourceType, sourceName);
|
|
|
|
if (!records.Any())
|
|
{
|
|
transferMessage = "Nessun record trovato nella fonte dati selezionata.";
|
|
transferMessageType = "error";
|
|
return;
|
|
}
|
|
|
|
// 2. Ottieni i campi obbligatori dell'entità REST (se non ci sono campi chiave)
|
|
var requiredFields = new HashSet<string>();
|
|
if (!keyFields.Any() && restEntityDetails != null)
|
|
{
|
|
requiredFields = restEntityDetails.Properties
|
|
.Where(p => p.IsRequired && fieldMappings.ContainsValue(p.Name))
|
|
.Select(p => p.Name)
|
|
.ToHashSet();
|
|
|
|
Logger.LogInformation("Nessun campo chiave definito. Utilizzo {RequiredFieldsCount} campi obbligatori per controllo duplicati: {RequiredFields}",
|
|
requiredFields.Count, string.Join(", ", requiredFields));
|
|
}
|
|
|
|
// 3. Trasforma e trasferisci ogni record
|
|
int successCount = 0;
|
|
int errorCount = 0;
|
|
int updatedCount = 0;
|
|
int duplicateCount = 0;
|
|
var errors = new List<string>();
|
|
int recordNumber = 1;
|
|
|
|
foreach (var record in records)
|
|
{
|
|
var transferResult = new TransferResult
|
|
{
|
|
RecordNumber = recordNumber,
|
|
RecordData = new Dictionary<string, object>(record)
|
|
};
|
|
|
|
try
|
|
{
|
|
// Trasforma il record in base ai mapping
|
|
var restData = TransformRecordToRestEntity(record);
|
|
|
|
// Genera la chiave sorgente per questo record
|
|
var sourceKey = GenerateSourceKey(record);
|
|
|
|
// NUOVO SISTEMA: Cerca associazione esistente basata sul valore della chiave
|
|
if (useRecordAssociations && !string.IsNullOrEmpty(sourceKey))
|
|
{
|
|
Logger.LogInformation("ASSOCIATION DEBUG: Cerco associazione - KeyValue: '{KeyValue}', Entity: '{Entity}', Credential: '{Credential}'",
|
|
sourceKey, selectedRestEntity.Name, selectedRestCredential);
|
|
|
|
// Cerca se esiste già un'associazione per questo valore chiave
|
|
var existingAssociation = await CredentialService.FindKeyAssociationByValueAsync(
|
|
sourceKey, selectedRestEntity.Name, selectedRestCredential);
|
|
|
|
// FALLBACK: Se non troviamo l'associazione con tutti i parametri, proviamo solo con il KeyValue
|
|
if (existingAssociation == null)
|
|
{
|
|
Logger.LogWarning("ASSOCIATION DEBUG: Associazione non trovata con parametri specifici, provo solo con KeyValue: '{KeyValue}'", sourceKey);
|
|
existingAssociation = await CredentialService.FindKeyAssociationByValueAsync(sourceKey);
|
|
|
|
if (existingAssociation != null)
|
|
{
|
|
Logger.LogWarning("ASSOCIATION DEBUG: Trovata associazione con fallback - ID: {AssociationId}, Entity: '{Entity}', Credential: '{Credential}'",
|
|
existingAssociation.Id, existingAssociation.DestinationEntity, existingAssociation.RestCredentialName);
|
|
|
|
// Verifica se l'associazione trovata è compatibile
|
|
if (existingAssociation.DestinationEntity != selectedRestEntity.Name ||
|
|
existingAssociation.RestCredentialName != selectedRestCredential)
|
|
{
|
|
Logger.LogWarning("ASSOCIATION DEBUG: Associazione non compatibile - Entity: '{FoundEntity}' vs '{ExpectedEntity}', Credential: '{FoundCredential}' vs '{ExpectedCredential}'",
|
|
existingAssociation.DestinationEntity, selectedRestEntity.Name, existingAssociation.RestCredentialName, selectedRestCredential);
|
|
existingAssociation = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
Logger.LogInformation("ASSOCIATION DEBUG: Associazione finale: {Found}. ID: {AssociationId}, DestinationId: '{DestinationId}', IsActive: {IsActive}",
|
|
existingAssociation != null, existingAssociation?.Id, existingAssociation?.DestinationId, existingAssociation?.IsActive);
|
|
|
|
if (existingAssociation != null && existingAssociation.IsActive)
|
|
{
|
|
// Prova direttamente l'aggiornamento - più efficiente che verificare prima l'esistenza
|
|
Logger.LogInformation("ASSOCIATION DEBUG: Tentativo aggiornamento record esistente - DestinationId: '{DestinationId}'", existingAssociation.DestinationId);
|
|
|
|
try
|
|
{
|
|
var updateResult = await currentRestClient.UpdateEntityAsync(
|
|
selectedRestEntity.Name, existingAssociation.DestinationId, restData);
|
|
|
|
if (updateResult != null)
|
|
{
|
|
updatedCount++;
|
|
transferResult.Status = "updated";
|
|
transferResult.Message = $"Record aggiornato con successo tramite associazione (ID: {existingAssociation.DestinationId})";
|
|
transferResult.EntityId = existingAssociation.DestinationId;
|
|
|
|
// Aggiorna l'associazione con la data di ultimo aggiornamento e verifica
|
|
existingAssociation.UpdatedAt = DateTime.UtcNow;
|
|
existingAssociation.LastVerifiedAt = DateTime.UtcNow;
|
|
await CredentialService.UpdateKeyAssociationAsync(existingAssociation);
|
|
|
|
Logger.LogInformation("ASSOCIATION DEBUG: Record aggiornato con successo tramite associazione: {EntityId} per valore chiave {KeyValue}",
|
|
existingAssociation.DestinationId, sourceKey);
|
|
|
|
transferResults.Add(transferResult);
|
|
recordNumber++;
|
|
continue;
|
|
}
|
|
else
|
|
{
|
|
// Update fallito ma senza eccezione - probabilmente l'entità non esiste più
|
|
Logger.LogWarning("ASSOCIATION DEBUG: Aggiornamento fallito (result null) per associazione {AssociationId} - elimino associazione e creo nuovo record", existingAssociation.Id);
|
|
goto HandleInvalidAssociation;
|
|
}
|
|
}
|
|
catch (Exception updateEx)
|
|
{
|
|
// Update fallito con eccezione - probabilmente l'entità non esiste più
|
|
Logger.LogWarning(updateEx, "ASSOCIATION DEBUG: Aggiornamento fallito per associazione {AssociationId} - elimino associazione e creo nuovo record", existingAssociation.Id);
|
|
goto HandleInvalidAssociation;
|
|
}
|
|
|
|
HandleInvalidAssociation:
|
|
// L'ID di destinazione non esiste più o l'update è fallito - elimina l'associazione non valida
|
|
try
|
|
{
|
|
await CredentialService.DeleteKeyAssociationAsync(existingAssociation.Id);
|
|
Logger.LogInformation("ASSOCIATION DEBUG: Associazione non valida eliminata: {AssociationId}", existingAssociation.Id);
|
|
}
|
|
catch (Exception delEx)
|
|
{
|
|
Logger.LogWarning(delEx, "Errore nell'eliminazione dell'associazione non valida {AssociationId}", existingAssociation.Id);
|
|
}
|
|
|
|
transferResult.Status = "info";
|
|
transferResult.Message = $"Associazione non valida eliminata (aggiornamento fallito) - creazione nuovo record";
|
|
|
|
// Procedi con la creazione di un nuovo record (non aggiungere il result qui, sarà aggiunto dopo CreateNewRecord)
|
|
}
|
|
}
|
|
|
|
CreateNewRecord:
|
|
// Crea un nuovo record
|
|
var result = await currentRestClient.CreateEntityAsync(selectedRestEntity.Name, restData);
|
|
|
|
if (result != null)
|
|
{
|
|
successCount++;
|
|
transferResult.Status = "success";
|
|
transferResult.Message = "Record inserito con successo";
|
|
transferResult.EntityId = result.ContainsKey("id") ? result["id"]?.ToString() :
|
|
result.ContainsKey("Id") ? result["Id"]?.ToString() :
|
|
result.ContainsKey("DocEntry") ? result["DocEntry"]?.ToString() : null;
|
|
|
|
// Crea associazione solo se abbiamo una chiave sorgente e un ID destinazione
|
|
if (useRecordAssociations && !string.IsNullOrEmpty(sourceKey) && !string.IsNullOrEmpty(transferResult.EntityId))
|
|
{
|
|
try
|
|
{
|
|
// Determina i campi chiave automaticamente
|
|
var destinationKeyField = GetEntityIdField(); // Campo chiave nella destinazione
|
|
|
|
var association = new KeyAssociation
|
|
{
|
|
KeyValue = sourceKey,
|
|
SourceKeyField = sourceKeyField,
|
|
DestinationKeyField = destinationKeyField,
|
|
DestinationEntity = selectedRestEntity.Name,
|
|
DestinationId = transferResult.EntityId,
|
|
RestCredentialName = selectedRestCredential,
|
|
CreatedAt = DateTime.UtcNow,
|
|
LastVerifiedAt = DateTime.UtcNow,
|
|
AdditionalInfo = System.Text.Json.JsonSerializer.Serialize(new
|
|
{
|
|
TransferDate = DateTime.UtcNow,
|
|
RecordNumber = recordNumber,
|
|
MappingCount = fieldMappings.Count,
|
|
SourceType = selectedSourceType
|
|
})
|
|
};
|
|
|
|
Logger.LogInformation("ASSOCIATION DEBUG: Creazione nuova associazione - KeyValue: '{KeyValue}', Entity: '{Entity}', DestinationId: '{DestinationId}', Credential: '{Credential}'",
|
|
sourceKey, selectedRestEntity.Name, transferResult.EntityId, selectedRestCredential);
|
|
|
|
var associationId = await CredentialService.SaveKeyAssociationAsync(association);
|
|
Logger.LogInformation("DEBUG: Associazione salvata con ID: {AssociationId}", associationId);
|
|
}
|
|
catch (Exception assocEx)
|
|
{
|
|
Logger.LogWarning(assocEx, "Errore nella creazione dell'associazione per record {RecordNumber}", recordNumber);
|
|
// Non interrompiamo il trasferimento per errori di associazione
|
|
}
|
|
}
|
|
|
|
Logger.LogDebug("Record trasferito con successo: {Data}", string.Join(", ", restData.Select(kvp => $"{kvp.Key}={kvp.Value}")));
|
|
}
|
|
else
|
|
{
|
|
errorCount++;
|
|
transferResult.Status = "error";
|
|
transferResult.Message = "Errore nel trasferimento del record (result null)";
|
|
errors.Add($"Errore nel trasferimento del record {recordNumber}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errorCount++;
|
|
transferResult.Status = "error";
|
|
transferResult.Message = $"Errore: {ex.Message}";
|
|
errors.Add($"Errore nel trasferimento del record {recordNumber}: {ex.Message}");
|
|
Logger.LogError(ex, "Errore nel trasferimento del record {RecordNumber}", recordNumber);
|
|
}
|
|
|
|
transferResults.Add(transferResult);
|
|
recordNumber++;
|
|
}
|
|
|
|
// 4. Mostra risultati
|
|
if (errorCount == 0)
|
|
{
|
|
var message = $"Trasferimento completato con successo! ";
|
|
var messageParts = new List<string>();
|
|
|
|
if (successCount > 0) messageParts.Add($"{successCount} record inseriti");
|
|
if (updatedCount > 0) messageParts.Add($"{updatedCount} record aggiornati");
|
|
if (duplicateCount > 0) messageParts.Add($"{duplicateCount} duplicati rilevati (warning)");
|
|
|
|
message += string.Join(", ", messageParts) + ".";
|
|
transferMessage = message;
|
|
transferMessageType = "success";
|
|
}
|
|
else
|
|
{
|
|
var message = $"Trasferimento completato con {(duplicateCount > 0 ? "warning e " : "")}errori. ";
|
|
var messageParts = new List<string>();
|
|
|
|
if (successCount > 0) messageParts.Add($"Inserimenti: {successCount}");
|
|
if (updatedCount > 0) messageParts.Add($"Aggiornamenti: {updatedCount}");
|
|
if (duplicateCount > 0) messageParts.Add($"Duplicati (warning): {duplicateCount}");
|
|
messageParts.Add($"Errori: {errorCount}");
|
|
|
|
message += string.Join(", ", messageParts);
|
|
if (errors.Any())
|
|
{
|
|
message += $". Primi errori: {string.Join("; ", errors.Take(3))}";
|
|
}
|
|
transferMessage = message;
|
|
transferMessageType = errorCount > 0 ? "error" : "warning";
|
|
}
|
|
|
|
Logger.LogInformation("Trasferimento completato. Inserimenti: {SuccessCount}, Aggiornamenti: {UpdatedCount}, Duplicati: {DuplicateCount}, Errori: {ErrorCount}",
|
|
successCount, updatedCount, duplicateCount, errorCount);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore generale nel trasferimento dati");
|
|
transferMessage = $"Errore nel trasferimento dati: {ex.Message}";
|
|
transferMessageType = "error";
|
|
}
|
|
finally
|
|
{
|
|
isTransferringData = false;
|
|
}
|
|
} private async Task<IEnumerable<Dictionary<string, object>>> GetAllRecordsFromSource()
|
|
{
|
|
if (selectedSourceType == "database")
|
|
{
|
|
return await GetAllRecordsFromDatabase();
|
|
}
|
|
else if (selectedSourceType == "file")
|
|
{
|
|
return await GetAllRecordsFromFile();
|
|
}
|
|
|
|
return new List<Dictionary<string, object>>();
|
|
}
|
|
|
|
private async Task<IEnumerable<Dictionary<string, object>>> GetAllRecordsFromDatabase()
|
|
{
|
|
if (currentDatabaseManager == null)
|
|
return new List<Dictionary<string, object>>();
|
|
|
|
try
|
|
{
|
|
if (useCustomQuery)
|
|
{
|
|
// Usa la query custom per ottenere tutti i record
|
|
if (!isQueryValid || string.IsNullOrWhiteSpace(customQuery))
|
|
{
|
|
throw new InvalidOperationException("Query custom non valida. Validare la query prima di procedere.");
|
|
}
|
|
|
|
// CONTROLLO DI SICUREZZA AGGIUNTIVO: Verifica che sia ancora una SELECT
|
|
if (!IsSelectQuery(customQuery))
|
|
{
|
|
throw new InvalidOperationException("ERRORE DI SICUREZZA: Tentativo di eseguire una query non SELECT. Operazione bloccata per sicurezza.");
|
|
}
|
|
|
|
var cleanQuery = CleanQuery(customQuery);
|
|
Logger.LogInformation("Esecuzione query custom per trasferimento dati: {Query}", cleanQuery);
|
|
|
|
return await currentDatabaseManager.ExecuteRawQueryAsync(cleanQuery);
|
|
}
|
|
else
|
|
{
|
|
// Usa il metodo standard per tabelle
|
|
if (string.IsNullOrEmpty(selectedTable))
|
|
{
|
|
throw new InvalidOperationException("Nessuna tabella selezionata.");
|
|
}
|
|
|
|
return await currentDatabaseManager.GetAllRecordsAsync(selectedTable);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nell'ottenere i record dal database. UseCustomQuery: {UseCustomQuery}, Table: {Table}, Query: {Query}",
|
|
useCustomQuery, selectedTable, useCustomQuery ? customQuery : "N/A");
|
|
throw;
|
|
}
|
|
} private async Task<IEnumerable<Dictionary<string, object>>> GetAllRecordsFromFile()
|
|
{
|
|
if (string.IsNullOrEmpty(selectedSheet) || !fileData.ContainsKey(selectedSheet))
|
|
{
|
|
return new List<Dictionary<string, object>>();
|
|
}
|
|
|
|
await Task.CompletedTask;
|
|
return fileData[selectedSheet];
|
|
}
|
|
|
|
private Dictionary<string, object> TransformRecordToRestEntity(Dictionary<string, object> dbRecord)
|
|
{
|
|
var restData = new Dictionary<string, object>();
|
|
|
|
foreach (var mapping in fieldMappings)
|
|
{
|
|
string dbColumn = mapping.Key;
|
|
string restProperty = mapping.Value;
|
|
|
|
if (dbRecord.ContainsKey(dbColumn))
|
|
{
|
|
var value = dbRecord[dbColumn];
|
|
|
|
// Trasforma il valore se necessario (es. date format, null handling, etc.)
|
|
var transformedValue = TransformValue(value, dbColumn, restProperty);
|
|
|
|
if (transformedValue != null)
|
|
{
|
|
restData[restProperty] = transformedValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
Logger.LogDebug("Record trasformato: {DbColumns} → {RestProperties}",
|
|
string.Join(", ", dbRecord.Keys),
|
|
string.Join(", ", restData.Keys));
|
|
|
|
return restData;
|
|
}
|
|
|
|
private object? TransformValue(object? value, string dbColumn, string restProperty)
|
|
{
|
|
if (value == null || value == DBNull.Value)
|
|
return null;
|
|
|
|
// Ottieni informazioni sui tipi per fare trasformazioni intelligenti
|
|
var dbColumnInfo = databaseTables.ContainsKey(selectedTable)
|
|
? databaseTables[selectedTable].FirstOrDefault(c => c.Name == dbColumn)
|
|
: null;
|
|
|
|
var restPropertyInfo = restEntityDetails?.Properties.FirstOrDefault(p => p.Name == restProperty);
|
|
|
|
// Trasformazioni specifiche per tipo
|
|
if (restPropertyInfo != null)
|
|
{
|
|
switch (restPropertyInfo.Type.ToLower())
|
|
{
|
|
case "edm.string":
|
|
return value.ToString();
|
|
|
|
case "edm.int32":
|
|
case "edm.int64":
|
|
if (int.TryParse(value.ToString(), out int intVal))
|
|
return intVal;
|
|
break;
|
|
|
|
case "edm.decimal":
|
|
case "edm.double":
|
|
if (decimal.TryParse(value.ToString(), out decimal decVal))
|
|
return decVal;
|
|
break;
|
|
|
|
case "edm.boolean":
|
|
if (bool.TryParse(value.ToString(), out bool boolVal))
|
|
return boolVal;
|
|
// Gestisci anche valori numerici (0/1) come boolean
|
|
if (value.ToString() == "1") return true;
|
|
if (value.ToString() == "0") return false;
|
|
break;
|
|
|
|
case "edm.datetime":
|
|
case "edm.datetimeoffset":
|
|
if (DateTime.TryParse(value.ToString(), out DateTime dateVal))
|
|
return dateVal.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Fallback: restituisci il valore convertito a stringa
|
|
return value.ToString();
|
|
}
|
|
|
|
private string GetPropertyPlaceholder(RestPropertyInfo property)
|
|
{
|
|
return property.Type switch
|
|
{
|
|
"Edm.String" => $"Inserisci {property.Name}" + (property.MaxLength.HasValue ? $" (max {property.MaxLength})" : ""),
|
|
"Edm.Int32" => "Numero intero",
|
|
"Edm.Decimal" => "Numero decimale",
|
|
"Edm.DateTime" => "Data/Ora (YYYY-MM-DD)",
|
|
"Edm.Boolean" => "true/false",
|
|
_ => $"Valore per {property.Name}"
|
|
};
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
currentDatabaseManager?.Dispose();
|
|
}
|
|
|
|
private char DetectCsvSeparator(string line)
|
|
{
|
|
// Common separators to check
|
|
var separators = new[] { ',', ';', '\t', '|' };
|
|
var counts = new Dictionary<char, int>();
|
|
|
|
bool inQuotes = false;
|
|
|
|
// Count separators outside of quotes
|
|
foreach (char c in line)
|
|
{
|
|
if (c == '"')
|
|
{
|
|
inQuotes = !inQuotes;
|
|
}
|
|
else if (!inQuotes && separators.Contains(c))
|
|
{
|
|
counts[c] = counts.GetValueOrDefault(c, 0) + 1;
|
|
}
|
|
}
|
|
|
|
// Return the separator with the highest count, default to comma
|
|
if (counts.Any())
|
|
{
|
|
var mostCommon = counts.OrderByDescending(x => x.Value).First();
|
|
// Make sure we have at least one occurrence to avoid single-column files
|
|
if (mostCommon.Value > 0)
|
|
{
|
|
return mostCommon.Key;
|
|
}
|
|
}
|
|
return ','; // Default fallback
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifica se il pulsante di trasferimento può essere abilitato
|
|
/// </summary>
|
|
private bool IsTransferButtonEnabled()
|
|
{
|
|
// Base requirements
|
|
if (!fieldMappings.Any())
|
|
return false;
|
|
|
|
// Se il sistema di associazioni è abilitato, il campo chiave sorgente è obbligatorio
|
|
if (useRecordAssociations && string.IsNullOrEmpty(sourceKeyField))
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
// Helper methods per UI risultati
|
|
private string GetResultRowClass(string status)
|
|
{
|
|
return status switch
|
|
{
|
|
"success" => "",
|
|
"updated" => "table-info",
|
|
"duplicate" => "table-warning",
|
|
"error" => "table-danger",
|
|
_ => ""
|
|
};
|
|
}
|
|
|
|
private string GetResultBadgeClass(string status)
|
|
{
|
|
return status switch
|
|
{
|
|
"success" => "bg-success",
|
|
"updated" => "bg-info",
|
|
"duplicate" => "bg-warning text-dark",
|
|
"error" => "bg-danger",
|
|
_ => "bg-secondary"
|
|
};
|
|
}
|
|
|
|
private string GetResultIcon(string status)
|
|
{
|
|
return status switch
|
|
{
|
|
"success" => "fa-check-circle",
|
|
"updated" => "fa-edit",
|
|
"duplicate" => "fa-exclamation-triangle",
|
|
"error" => "fa-times-circle",
|
|
_ => "fa-question-circle"
|
|
};
|
|
}
|
|
|
|
private string GetResultStatusText(string status)
|
|
{
|
|
return status switch
|
|
{
|
|
"success" => "Inserito",
|
|
"updated" => "Aggiornato",
|
|
"duplicate" => "Duplicato",
|
|
"error" => "Errore",
|
|
_ => "Sconosciuto"
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Genera una chiave univoca per il record sorgente
|
|
/// </summary>
|
|
private string GenerateSourceKey(Dictionary<string, object> record)
|
|
{
|
|
try
|
|
{
|
|
// Il campo chiave sorgente deve essere sempre specificato
|
|
if (string.IsNullOrEmpty(sourceKeyField))
|
|
{
|
|
throw new InvalidOperationException("Campo chiave sorgente non specificato. La selezione del campo chiave è obbligatoria.");
|
|
}
|
|
|
|
if (!record.ContainsKey(sourceKeyField))
|
|
{
|
|
throw new InvalidOperationException($"Il campo chiave '{sourceKeyField}' non è presente nel record sorgente.");
|
|
}
|
|
|
|
var keyValue = record[sourceKeyField]?.ToString();
|
|
if (string.IsNullOrEmpty(keyValue))
|
|
{
|
|
throw new InvalidOperationException($"Il valore del campo chiave '{sourceKeyField}' è vuoto o null per questo record.");
|
|
}
|
|
|
|
// Normalizza il valore della chiave (trim e gestione case-sensitive)
|
|
return keyValue.Trim();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nella generazione della chiave sorgente per il campo {SourceKeyField}", sourceKeyField);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private async Task HandleDatabaseSelectionRequired()
|
|
{
|
|
try
|
|
{
|
|
if (currentDatabaseManager == null)
|
|
{
|
|
databaseErrorMessage = "Database manager non inizializzato";
|
|
return;
|
|
}
|
|
|
|
// Ottieni la lista dei database disponibili
|
|
availableDatabases = await currentDatabaseManager.GetAvailableDatabasesAsync();
|
|
|
|
if (availableDatabases != null && availableDatabases.Any())
|
|
{
|
|
// Mostra il modal per la selezione del database
|
|
showDatabaseSelectionModal = true;
|
|
StateHasChanged();
|
|
}
|
|
else
|
|
{
|
|
databaseErrorMessage = "Nessun database disponibile per la selezione";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nell'ottenere la lista dei database disponibili");
|
|
databaseErrorMessage = $"Errore nel recupero dei database: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private async Task OnDatabaseSelected()
|
|
{
|
|
if (string.IsNullOrEmpty(selectedDatabase))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (currentDatabaseManager == null)
|
|
{
|
|
databaseErrorMessage = "Database manager non inizializzato";
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Cambia il database attivo
|
|
await currentDatabaseManager.ChangeDatabaseAsync(selectedDatabase);
|
|
|
|
// Nasconde il modal
|
|
showDatabaseSelectionModal = false;
|
|
|
|
// Ritenta il discovery dello schema
|
|
var schema = await currentDatabaseManager.GetDatabaseSchemaAsync();
|
|
databaseTables = schema as Dictionary<string, IEnumerable<DbColumnInfo>> ??
|
|
(schema != null ? new Dictionary<string, IEnumerable<DbColumnInfo>>(schema) : new Dictionary<string, IEnumerable<DbColumnInfo>>());
|
|
|
|
if (databaseTables.Count == 0)
|
|
{
|
|
databaseErrorMessage = $"Il database '{selectedDatabase}' non contiene tabelle accessibili";
|
|
}
|
|
else
|
|
{
|
|
isDatabaseConnected = true;
|
|
databaseErrorMessage = "";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Errore nel cambio di database a {Database}", selectedDatabase);
|
|
databaseErrorMessage = $"Errore nel cambio di database: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private void CancelDatabaseSelection()
|
|
{
|
|
showDatabaseSelectionModal = false;
|
|
selectedDatabase = "";
|
|
StateHasChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ottiene il nome del campo ID per l'entità corrente
|
|
/// </summary>
|
|
private string GetEntityIdField()
|
|
{
|
|
// Fallback predefiniti in base al tipo di servizio/entità
|
|
if (selectedRestEntity?.Name != null)
|
|
{
|
|
// Per SAP B1, la maggior parte delle entità usa DocEntry
|
|
if (selectedRestEntity.Name.Contains("BusinessPartner") ||
|
|
selectedRestEntity.Name.Contains("Customer") ||
|
|
selectedRestEntity.Name.Contains("Vendor"))
|
|
{
|
|
return "CardCode";
|
|
}
|
|
|
|
if (selectedRestEntity.Name.Contains("Item") ||
|
|
selectedRestEntity.Name.Contains("Product"))
|
|
{
|
|
return "ItemCode";
|
|
}
|
|
}
|
|
|
|
// Usa campi ID comuni come fallback
|
|
var commonIdFields = new[] { "DocEntry", "Id", "ID", "id", "Key", "key", "Code", "code" };
|
|
|
|
// Per ora usa DocEntry come default per SAP B1
|
|
return "DocEntry";
|
|
}
|
|
|
|
// Custom Query Methods
|
|
private void OnQueryModeChanged(ChangeEventArgs e)
|
|
{
|
|
useCustomQuery = bool.Parse(e.Value?.ToString() ?? "false");
|
|
|
|
if (useCustomQuery)
|
|
{
|
|
// Reset table selection when switching to custom query
|
|
selectedTable = "";
|
|
ClearAllMappings();
|
|
|
|
// Reset query-specific state
|
|
customQuery = "";
|
|
isQueryValid = false;
|
|
queryValidationMessage = "";
|
|
queryPreviewData.Clear();
|
|
queryColumns.Clear();
|
|
showQueryPreview = false;
|
|
|
|
// For custom queries, require manual key selection
|
|
sourceKeyField = "";
|
|
suggestedPrimaryKey = "";
|
|
requiresManualKeySelection = true;
|
|
}
|
|
else
|
|
{
|
|
// Reset custom query when switching to table mode
|
|
customQuery = "";
|
|
isQueryValid = false;
|
|
queryValidationMessage = "";
|
|
queryPreviewData.Clear();
|
|
queryColumns.Clear();
|
|
showQueryPreview = false;
|
|
ClearAllMappings();
|
|
|
|
// Reset key field selection
|
|
sourceKeyField = "";
|
|
suggestedPrimaryKey = "";
|
|
requiresManualKeySelection = false;
|
|
}
|
|
|
|
StateHasChanged();
|
|
}
|
|
|
|
private async Task ValidateCustomQuery()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(customQuery) || currentDatabaseManager == null)
|
|
{
|
|
isQueryValid = false;
|
|
queryValidationMessage = "Query vuota o database non connesso";
|
|
return;
|
|
}
|
|
|
|
// CONTROLLO DI SICUREZZA: Verifica che sia una SELECT
|
|
if (!IsSelectQuery(customQuery))
|
|
{
|
|
isQueryValid = false;
|
|
queryValidationMessage = "ERRORE DI SICUREZZA: Sono permesse solo query SELECT. Operazioni come INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE non sono consentite.";
|
|
Logger.LogWarning("Tentativo di eseguire query non SELECT bloccato: {Query}", customQuery.Length > 100 ? customQuery.Substring(0, 100) + "..." : customQuery);
|
|
return;
|
|
}
|
|
|
|
isValidatingQuery = true;
|
|
queryValidationMessage = "";
|
|
queryColumns.Clear();
|
|
|
|
try
|
|
{
|
|
// Converte la query per testare solo 1 riga
|
|
var testQuery = ConvertQueryForValidation(customQuery);
|
|
|
|
Logger.LogInformation("Validazione query: {TestQuery}", testQuery);
|
|
|
|
// Esegue la query di test
|
|
var testResults = await currentDatabaseManager.ExecuteRawQueryAsync(testQuery);
|
|
|
|
if (testResults != null && testResults.Any())
|
|
{
|
|
isQueryValid = true;
|
|
|
|
// Estrae i nomi delle colonne dal primo record
|
|
var firstRecord = testResults.First();
|
|
queryColumns = firstRecord.Keys.ToList();
|
|
|
|
// Non mostra più messaggi di successo per ridurre l'ingombro visivo
|
|
queryValidationMessage = "";
|
|
|
|
Logger.LogInformation("Query validata con successo. Colonne: {Columns}", string.Join(", ", queryColumns));
|
|
|
|
// Clear existing mappings since we have new columns
|
|
fieldMappings.Clear();
|
|
selectedDbColumn = "";
|
|
selectedRestProperty = "";
|
|
|
|
// For custom queries, always require manual key selection
|
|
sourceKeyField = "";
|
|
suggestedPrimaryKey = "";
|
|
requiresManualKeySelection = true;
|
|
|
|
StateHasChanged();
|
|
}
|
|
else
|
|
{
|
|
isQueryValid = false;
|
|
queryValidationMessage = "Query valida ma non restituisce risultati";
|
|
queryColumns.Clear();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
isQueryValid = false;
|
|
queryValidationMessage = $"Errore nella validazione: {ex.Message}";
|
|
queryColumns.Clear();
|
|
Logger.LogError(ex, "Errore nella validazione della query custom");
|
|
}
|
|
finally
|
|
{
|
|
isValidatingQuery = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private async Task LoadQueryPreview()
|
|
{
|
|
if (!isQueryValid || string.IsNullOrWhiteSpace(customQuery) || currentDatabaseManager == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
isLoadingPreview = true;
|
|
|
|
try
|
|
{
|
|
// Usa la query limitata per il preview (max 50 righe per performance)
|
|
var previewQuery = ConvertQueryForPreview(customQuery, 50);
|
|
|
|
Logger.LogInformation("Caricamento preview query: {PreviewQuery}", previewQuery);
|
|
|
|
queryPreviewData = await currentDatabaseManager.ExecuteRawQueryAsync(previewQuery);
|
|
showQueryPreview = true;
|
|
|
|
Logger.LogInformation("Preview caricato: {RowCount} righe", queryPreviewData.Count);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
queryValidationMessage = $"Errore nel caricamento preview: {ex.Message}";
|
|
Logger.LogError(ex, "Errore nel caricamento del preview della query");
|
|
}
|
|
finally
|
|
{
|
|
isLoadingPreview = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private void HideQueryPreview()
|
|
{
|
|
showQueryPreview = false;
|
|
queryPreviewData.Clear();
|
|
StateHasChanged();
|
|
}
|
|
|
|
private string ConvertQueryForValidation(string originalQuery)
|
|
{
|
|
// Rimuove commenti e spazi extra
|
|
var cleanQuery = CleanQuery(originalQuery);
|
|
|
|
// Se la query ha già un LIMIT/TOP, la usa così com'è per il test
|
|
if (HasLimitClause(cleanQuery))
|
|
{
|
|
return cleanQuery;
|
|
}
|
|
|
|
// Aggiunge LIMIT/TOP in base al tipo di database
|
|
return AddLimitClause(cleanQuery, 1);
|
|
}
|
|
|
|
private string ConvertQueryForPreview(string originalQuery, int maxRows = 50)
|
|
{
|
|
var cleanQuery = CleanQuery(originalQuery);
|
|
|
|
// Se la query ha già un LIMIT/TOP con un valore minore, la mantiene
|
|
if (HasLimitClause(cleanQuery))
|
|
{
|
|
return cleanQuery;
|
|
}
|
|
|
|
return AddLimitClause(cleanQuery, maxRows);
|
|
}
|
|
|
|
private string CleanQuery(string query)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(query))
|
|
return "";
|
|
|
|
// Rimuove commenti SQL
|
|
var lines = query.Split('\n')
|
|
.Select(line => line.Contains("--") ? line.Substring(0, line.IndexOf("--")) : line)
|
|
.Where(line => !string.IsNullOrWhiteSpace(line));
|
|
|
|
var cleanQuery = string.Join(" ", lines).Trim();
|
|
|
|
// Rimuove il punto e virgola finale se presente
|
|
if (cleanQuery.EndsWith(";"))
|
|
{
|
|
cleanQuery = cleanQuery.Substring(0, cleanQuery.Length - 1);
|
|
}
|
|
|
|
return cleanQuery;
|
|
}
|
|
|
|
private bool HasLimitClause(string query)
|
|
{
|
|
var upperQuery = query.ToUpperInvariant();
|
|
return upperQuery.Contains(" LIMIT ") ||
|
|
upperQuery.Contains(" TOP ") ||
|
|
upperQuery.Contains("ROWNUM") ||
|
|
upperQuery.Contains("FETCH FIRST");
|
|
}
|
|
|
|
private string AddLimitClause(string query, int limit)
|
|
{
|
|
var upperQuery = query.ToUpperInvariant();
|
|
|
|
// Per SQL Server, Oracle, e altri che supportano TOP
|
|
if (upperQuery.Contains("SELECT "))
|
|
{
|
|
var credential = databaseCredentials.FirstOrDefault(c => c.Name == selectedDatabaseCredential);
|
|
if (credential != null)
|
|
{
|
|
var dbType = credential.DatabaseType.ToString().ToLowerInvariant();
|
|
switch (dbType)
|
|
{
|
|
case "sqlserver":
|
|
case "oracle":
|
|
// Aggiunge TOP dopo SELECT
|
|
return query.Replace("SELECT ", $"SELECT TOP {limit} ", StringComparison.OrdinalIgnoreCase);
|
|
|
|
case "mysql":
|
|
case "postgresql":
|
|
case "sqlite":
|
|
default:
|
|
// Aggiunge LIMIT alla fine
|
|
return $"{query} LIMIT {limit}";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: aggiunge LIMIT
|
|
return $"{query} LIMIT {limit}";
|
|
}
|
|
|
|
private void OnCustomQueryChanged(ChangeEventArgs e)
|
|
{
|
|
customQuery = e.Value?.ToString() ?? "";
|
|
|
|
// Reset validation quando la query cambia
|
|
isQueryValid = false;
|
|
queryValidationMessage = "";
|
|
queryPreviewData.Clear();
|
|
queryColumns.Clear();
|
|
showQueryPreview = false;
|
|
|
|
// Clear mappings quando la query cambia
|
|
ClearAllMappings();
|
|
|
|
// Reset key field selection
|
|
sourceKeyField = "";
|
|
suggestedPrimaryKey = "";
|
|
requiresManualKeySelection = true;
|
|
|
|
StateHasChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifica che la query sia una SELECT e non contenga operazioni pericolose
|
|
/// </summary>
|
|
private bool IsSelectQuery(string query)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(query))
|
|
return false;
|
|
|
|
// Rimuovi commenti e normalizza la query
|
|
var cleanQuery = CleanQueryForSecurityCheck(query);
|
|
|
|
// Lista delle operazioni pericolose che non sono permesse
|
|
var dangerousKeywords = new[]
|
|
{
|
|
"INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER",
|
|
"TRUNCATE", "REPLACE", "MERGE", "EXEC", "EXECUTE",
|
|
"DECLARE", "SET", "GRANT", "REVOKE", "BACKUP", "RESTORE",
|
|
"SHUTDOWN", "KILL", "LOAD", "BULK", "OPENROWSET", "OPENDATASOURCE"
|
|
};
|
|
|
|
// Verifica che non contenga operazioni pericolose
|
|
foreach (var keyword in dangerousKeywords)
|
|
{
|
|
if (cleanQuery.Contains($" {keyword} ", StringComparison.OrdinalIgnoreCase) ||
|
|
cleanQuery.StartsWith($"{keyword} ", StringComparison.OrdinalIgnoreCase) ||
|
|
cleanQuery.Contains($";{keyword} ", StringComparison.OrdinalIgnoreCase) ||
|
|
cleanQuery.Contains($"\n{keyword} ", StringComparison.OrdinalIgnoreCase) ||
|
|
cleanQuery.Contains($"\r{keyword} ", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
Logger.LogWarning("Query bloccata: contiene keyword pericolosa '{Keyword}' in query: {QueryStart}",
|
|
keyword, query.Length > 50 ? query.Substring(0, 50) + "..." : query);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Verifica che inizi con SELECT (permettendo spazi e commenti iniziali)
|
|
var trimmedQuery = cleanQuery.TrimStart();
|
|
if (!trimmedQuery.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
Logger.LogWarning("Query bloccata: non inizia con SELECT. Query: {QueryStart}",
|
|
query.Length > 50 ? query.Substring(0, 50) + "..." : query);
|
|
return false;
|
|
}
|
|
|
|
// Verifica addizionale: non deve contenere punto e virgola seguito da altra query
|
|
var statements = cleanQuery.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
|
if (statements.Length > 1)
|
|
{
|
|
// Se ci sono multiple statements, tutte devono essere SELECT o commenti vuoti
|
|
foreach (var statement in statements)
|
|
{
|
|
var trimmedStatement = statement.Trim();
|
|
if (!string.IsNullOrEmpty(trimmedStatement) &&
|
|
!trimmedStatement.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
Logger.LogWarning("Query bloccata: contiene multiple statements non SELECT. Query: {QueryStart}",
|
|
query.Length > 50 ? query.Substring(0, 50) + "..." : query);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pulisce la query per il controllo di sicurezza rimuovendo commenti
|
|
/// </summary>
|
|
private string CleanQueryForSecurityCheck(string query)
|
|
{
|
|
if (string.IsNullOrEmpty(query))
|
|
return "";
|
|
|
|
var lines = query.Split('\n');
|
|
var cleanedLines = new List<string>();
|
|
|
|
foreach (var line in lines)
|
|
{
|
|
var cleanedLine = line.Trim();
|
|
|
|
// Rimuovi commenti SQL (-- e /* */)
|
|
var dashCommentIndex = cleanedLine.IndexOf("--");
|
|
if (dashCommentIndex >= 0)
|
|
{
|
|
cleanedLine = cleanedLine.Substring(0, dashCommentIndex).Trim();
|
|
}
|
|
|
|
// Gestione commenti multiline /* */ - implementazione base
|
|
cleanedLine = System.Text.RegularExpressions.Regex.Replace(cleanedLine, @"/\*.*?\*/", " ",
|
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
|
|
if (!string.IsNullOrWhiteSpace(cleanedLine))
|
|
{
|
|
cleanedLines.Add(cleanedLine);
|
|
}
|
|
}
|
|
|
|
return string.Join(" ", cleanedLines);
|
|
}
|
|
}
|