[Feature] Salesforce come fonte e Database come destinazione nel Data Coupler

Implementata la possibilita' di usare Salesforce come fonte dati e un database relazionale come destinazione nel flusso di trasferimento dati.

## Modifiche principali

### Nuovi file partial class
- Data_Coupler/Extensions/DataCoupler/SalesforceSourceMethod.cs
  - Stato e metodi per Salesforce come fonte (credenziali, connessione, discovery SObject)
  - ConnectToSalesforceSource(): autenticazione parallela + discovery summaries/details
  - SelectSalesforceSourceEntity(): selezione SObject e caricamento campi
  - GetAllRecordsFromSalesforceSource(): estrazione via ExtractAllEntitiesAsync con solo i campi mappati
  - Filtro/ricerca SObject in tempo reale

- Data_Coupler/Extensions/DataCoupler/DatabaseDestinationMethod.cs
  - Stato e metodi per database come destinazione
  - ConnectToDestinationDatabase(): connessione e discovery tabelle
  - SelectDestinationTable(): caricamento schema tabella on-demand
  - IsDestinationDatabaseReady: proprieta' calcolata per validazione
  - Toggle UI tra destinazione REST e Database

### IDatabaseManager interface
- Aggiunto UpsertRecordAsync(tableName, keyField, keyValue, record):
  - Esegue SELECT COUNT(*) per verificare esistenza record
  - UPDATE se esiste, INSERT se non esiste
  - Implementato in EFCoreDatabaseManager (parametri named @p0..@pN)
  - Implementato in OdbcDatabaseManager (parametri posizionali ?)

### DataCoupler.razor (UI)
- Aggiunto 'Salesforce (REST API)' nel dropdown tipo fonte
- Sezione UI Salesforce fonte: selettore credenziali, bottone connessione, lista SObject con ricerca
- Toggle destinazione REST/Database nella card destra
- Sezione UI Database destinazione: selettore credenziali, bottone connessione, lista tabelle con ricerca
- Colonna destra mapping aggiornata: mostra colonne DB se destinazione e' database, proprieta' REST altrimenti
- Colonna sinistra mapping: aggiunta sezione campi SObject Salesforce
- isSourceReady aggiornato per includere fonte Salesforce
- isDestinationReady aggiornato per includere destinazione database
- Etichette mapping dinamiche in base ai tipi selezionati

### DataCoupler.razor.cs (logica)
- LoadCredentials(): aggiunto caricamento credenziali Salesforce fonte
- ResetSourceState(): aggiunto reset stato Salesforce fonte
- ResetDestinationState(): aggiunto reset stato database destinazione
- GetAllRecordsFromSource(): aggiunto branch Salesforce
- StartDataTransfer(): routing verso StartDataTransferToDatabase() se dest=database
- Aggiunto StartDataTransferToDatabase(): estrae record fonte, applica mapping e default values, chiama UpsertRecordAsync per ogni record
- Rimosso codice duplicato in StartDataTransfer()
This commit is contained in:
2026-05-24 23:55:51 +02:00
parent b75e57fe31
commit 6452d45b77
8 changed files with 1091 additions and 41 deletions
@@ -0,0 +1,238 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
using CredentialManager.Models;
using DataConnection.Interfaces;
using Data_Coupler.Services;
namespace Data_Coupler.Pages;
/// <summary>
/// Partial class per la gestione di un database come destinazione dati.
/// Consente di selezionare un database di destinazione, scoprirne le tabelle
/// e configurare il mapping dei campi verso la tabella di destinazione.
/// </summary>
public partial class DataCoupler : ComponentBase
{
// ===== PROPRIETÀ TIPO DESTINAZIONE =====
/// <summary>
/// Tipo di destinazione: "rest" (default) oppure "database".
/// Controlla quale sezione UI viene mostrata nella card di destra.
/// </summary>
protected string selectedDestinationType = "rest";
// ===== PROPRIETÀ DATABASE DESTINAZIONE =====
/// <summary>Credenziale database selezionata come destinazione</summary>
protected string selectedDestinationDatabaseCredential = "";
/// <summary>Stato connessione in corso</summary>
protected bool isConnectingDestinationDatabase = false;
/// <summary>Database di destinazione connesso con successo</summary>
protected bool isDestinationDatabaseConnected = false;
/// <summary>Messaggio di errore connessione database destinazione</summary>
protected string destinationDatabaseErrorMessage = "";
/// <summary>Nomi delle tabelle disponibili nel database di destinazione</summary>
protected List<string> destAvailableTableNames = new();
/// <summary>Schema dettagliato per tabella di destinazione (caricato on-demand)</summary>
protected Dictionary<string, IEnumerable<DbColumnInfo>> destDatabaseTables = new();
/// <summary>Tabella di destinazione selezionata</summary>
protected string selectedDestinationTable = "";
/// <summary>Termine di ricerca per filtrare le tabelle di destinazione</summary>
protected string destDatabaseSearchTerm = "";
/// <summary>Database manager per il database di destinazione</summary>
protected IDatabaseManager? currentDestinationDatabaseManager = null;
// ===== METODI DATABASE DESTINAZIONE =====
/// <summary>
/// Gestisce il cambio del tipo di destinazione (rest / database)
/// </summary>
protected void OnDestinationTypeChanged(ChangeEventArgs e)
{
var newType = e.Value?.ToString() ?? "rest";
if (newType == selectedDestinationType)
return;
selectedDestinationType = newType;
// Reset lo stato della destinazione precedente
ResetDestinationState();
if (newType == "database")
{
ResetDestinationDatabaseState();
}
// Pulisce i mapping configurati (dipendono dal tipo di destinazione)
ClearAllMappings();
StateHasChanged();
}
/// <summary>
/// Gestisce il cambio della credenziale database di destinazione
/// </summary>
protected void OnDestinationDatabaseCredentialChanged(ChangeEventArgs e)
{
selectedDestinationDatabaseCredential = e.Value?.ToString() ?? "";
ResetDestinationDatabaseState();
}
/// <summary>
/// Resetta lo stato del database di destinazione
/// </summary>
protected void ResetDestinationDatabaseState()
{
isDestinationDatabaseConnected = false;
destAvailableTableNames.Clear();
destDatabaseTables.Clear();
selectedDestinationTable = "";
destDatabaseSearchTerm = "";
destinationDatabaseErrorMessage = "";
// Rilascia il database manager
if (currentDestinationDatabaseManager != null)
{
try { currentDestinationDatabaseManager.Dispose(); } catch { /* ignore */ }
currentDestinationDatabaseManager = null;
}
}
/// <summary>
/// Si connette al database di destinazione e carica le tabelle disponibili
/// </summary>
protected async Task ConnectToDestinationDatabase()
{
if (string.IsNullOrEmpty(selectedDestinationDatabaseCredential))
return;
isConnectingDestinationDatabase = true;
destinationDatabaseErrorMessage = "";
try
{
// Verifica credenziale
var credential = databaseCredentials.FirstOrDefault(c => c.Name == selectedDestinationDatabaseCredential);
if (credential == null)
{
destinationDatabaseErrorMessage = "Credenziale database non trovata";
return;
}
// Crea il database manager
currentDestinationDatabaseManager = await ConnectionFactory.CreateDatabaseManagerAsync(selectedDestinationDatabaseCredential);
// Verifica la connessione
var canConnect = await currentDestinationDatabaseManager.TestConnectionAsync();
if (!canConnect)
{
destinationDatabaseErrorMessage = "Impossibile connettersi al database di destinazione. Verificare le credenziali.";
currentDestinationDatabaseManager.Dispose();
currentDestinationDatabaseManager = null;
return;
}
// Carica i nomi delle tabelle
var tableNames = await currentDestinationDatabaseManager.GetTableNamesAsync();
destAvailableTableNames = tableNames.OrderBy(t => t).ToList();
isDestinationDatabaseConnected = true;
Logger.LogInformation("Database destinazione connesso: {Credential}, {Count} tabelle trovate",
selectedDestinationDatabaseCredential, destAvailableTableNames.Count);
}
catch (Exception ex)
{
destinationDatabaseErrorMessage = $"Errore di connessione: {ex.Message}";
Logger.LogError(ex, "Errore nella connessione al database destinazione: {Credential}", selectedDestinationDatabaseCredential);
if (currentDestinationDatabaseManager != null)
{
try { currentDestinationDatabaseManager.Dispose(); } catch { /* ignore */ }
currentDestinationDatabaseManager = null;
}
}
finally
{
isConnectingDestinationDatabase = false;
StateHasChanged();
}
}
/// <summary>
/// Seleziona la tabella di destinazione e carica il suo schema
/// </summary>
protected async Task SelectDestinationTable(string tableName)
{
selectedDestinationTable = tableName;
// Carica lo schema della tabella se non è già disponibile
if (currentDestinationDatabaseManager != null && !destDatabaseTables.ContainsKey(tableName))
{
try
{
Logger.LogInformation("Caricamento schema tabella destinazione: {TableName}", tableName);
var schema = await currentDestinationDatabaseManager.GetTableSchemaAsync(tableName);
destDatabaseTables[tableName] = schema;
Logger.LogInformation("Schema tabella destinazione caricato: {ColumnCount} colonne", schema.Count());
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore nel caricamento schema tabella destinazione {TableName}", tableName);
destinationDatabaseErrorMessage = $"Errore nel caricamento schema: {ex.Message}";
}
}
// Pulisce i mapping quando si cambia tabella
ClearAllMappings();
StateHasChanged();
}
/// <summary>
/// Restituisce la lista filtrata delle tabelle di destinazione
/// </summary>
protected IEnumerable<string> GetFilteredDestinationTables()
{
if (string.IsNullOrEmpty(destDatabaseSearchTerm))
return destAvailableTableNames;
return destAvailableTableNames
.Where(t => t.Contains(destDatabaseSearchTerm, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Aggiorna il termine di ricerca per le tabelle di destinazione
/// </summary>
protected void FilterDestinationTables(ChangeEventArgs e)
{
destDatabaseSearchTerm = e.Value?.ToString() ?? "";
StateHasChanged();
}
/// <summary>
/// Pulisce il termine di ricerca per le tabelle di destinazione
/// </summary>
protected void ClearDestinationTableSearch()
{
destDatabaseSearchTerm = "";
StateHasChanged();
}
/// <summary>
/// Indica se la configurazione corrente è pronta per il trasferimento verso database
/// </summary>
protected bool IsDestinationDatabaseReady =>
isDestinationDatabaseConnected &&
!string.IsNullOrEmpty(selectedDestinationTable) &&
currentDestinationDatabaseManager != null;
}
@@ -0,0 +1,299 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging;
using CredentialManager.Models;
using DataConnection.REST.Interfaces;
using DataConnection.REST.Models;
using Data_Coupler.Services;
namespace Data_Coupler.Pages;
/// <summary>
/// Partial class per la gestione di Salesforce come sorgente dati.
/// Consente di autenticarsi a Salesforce, scoprire gli SObject disponibili
/// e selezionare un'entità da cui estrarre i dati da trasferire.
/// </summary>
public partial class DataCoupler : ComponentBase
{
// ===== PROPRIETÀ SALESFORCE SOURCE =====
/// <summary>Credenziali Salesforce disponibili come sorgente</summary>
protected List<RestApiCredential> salesforceSourceCredentials = new();
/// <summary>Credenziale Salesforce selezionata come sorgente</summary>
protected string selectedSalesforceSourceCredential = "";
/// <summary>Stato connessione in corso</summary>
protected bool isConnectingSalesforceSource = false;
/// <summary>Salesforce source connessa con successo</summary>
protected bool isSalesforceSourceConnected = false;
/// <summary>Messaggio di errore connessione Salesforce source</summary>
protected string salesforceSourceErrorMessage = "";
/// <summary>Lista degli SObject Salesforce disponibili (summaries)</summary>
protected List<RestEntitySummary> salesforceSourceEntities = new();
/// <summary>SObject Salesforce selezionato come sorgente</summary>
protected RestEntitySummary? selectedSalesforceSourceEntity = null;
/// <summary>Dettagli (campi) dell'SObject selezionato</summary>
protected RestEntityInfo? salesforceSourceEntityDetails = null;
/// <summary>Termine di ricerca per filtrare gli SObject</summary>
protected string salesforceSourceSearchTerm = "";
/// <summary>Client REST per le operazioni Salesforce source</summary>
protected IRestServiceClient? currentSalesforceSourceClient = null;
/// <summary>Discovery metadata per Salesforce source</summary>
protected IRestMetadataDiscovery? currentSalesforceSourceDiscovery = null;
// ===== METODI SALESFORCE SOURCE =====
/// <summary>
/// Carica le credenziali di tipo Salesforce per usarle come sorgente
/// </summary>
protected async Task LoadSalesforceSourceCredentials()
{
try
{
var allCreds = await CredentialService.GetAllRestApiCredentialsAsync();
salesforceSourceCredentials = allCreds
.Where(c => c.ServiceType == RestServiceType.Salesforce)
.ToList();
Logger.LogInformation("Caricate {Count} credenziali Salesforce per uso come sorgente", salesforceSourceCredentials.Count);
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore nel caricamento delle credenziali Salesforce source");
}
}
/// <summary>
/// Gestisce il cambio di credenziale Salesforce source
/// </summary>
protected void OnSalesforceSourceCredentialChanged(ChangeEventArgs e)
{
var newCredential = e.Value?.ToString() ?? "";
// Pulisce la cache se si cambia credenziale
if (!string.IsNullOrEmpty(selectedSalesforceSourceCredential) && selectedSalesforceSourceCredential != newCredential)
{
try { ConnectionFactory.ClearRestClientCache(selectedSalesforceSourceCredential); } catch { /* ignore */ }
}
selectedSalesforceSourceCredential = newCredential;
ResetSalesforceSourceState();
}
/// <summary>
/// Resetta lo stato della connessione Salesforce source
/// </summary>
protected void ResetSalesforceSourceState()
{
isSalesforceSourceConnected = false;
salesforceSourceEntities.Clear();
selectedSalesforceSourceEntity = null;
salesforceSourceEntityDetails = null;
salesforceSourceSearchTerm = "";
salesforceSourceErrorMessage = "";
currentSalesforceSourceDiscovery = null;
currentSalesforceSourceClient = null;
}
/// <summary>
/// Si connette a Salesforce come sorgente e scopre gli SObject disponibili
/// </summary>
protected async Task ConnectToSalesforceSource()
{
if (string.IsNullOrEmpty(selectedSalesforceSourceCredential))
return;
isConnectingSalesforceSource = true;
salesforceSourceErrorMessage = "";
try
{
// Verifica la credenziale
var credential = salesforceSourceCredentials.FirstOrDefault(c => c.Name == selectedSalesforceSourceCredential);
if (credential == null)
{
salesforceSourceErrorMessage = "Credenziale Salesforce non trovata";
return;
}
// Crea i client usando il factory
currentSalesforceSourceClient = await ConnectionFactory.CreateRestServiceClientAsync(selectedSalesforceSourceCredential);
currentSalesforceSourceDiscovery = await ConnectionFactory.CreateRestMetadataDiscoveryAsync(selectedSalesforceSourceCredential);
// Autenticazione
Logger.LogInformation("Avvio autenticazione Salesforce source: {Credential}", selectedSalesforceSourceCredential);
var authResult = await currentSalesforceSourceClient.AuthenticateAsync();
if (!authResult)
{
salesforceSourceErrorMessage = "Autenticazione Salesforce fallita. Verificare le credenziali.";
currentSalesforceSourceClient = null;
currentSalesforceSourceDiscovery = null;
return;
}
// Discovery parallela: summaries veloci → UI interattiva subito; dettagli completi in background
Logger.LogInformation("Avvio discovery parallela SObject Salesforce source...");
var summariesTask = currentSalesforceSourceDiscovery.DiscoverEntitySummariesAsync();
var entitiesTask = currentSalesforceSourceDiscovery.DiscoverEntitiesAsync();
// Le summaries sono rapide (1 sola API call) → rendiamo la UI interattiva subito
salesforceSourceEntities = await summariesTask;
isSalesforceSourceConnected = true;
StateHasChanged();
Logger.LogInformation("SObject summaries caricate: {Count} entità disponibili", salesforceSourceEntities.Count);
// I dettagli completano in background (non bloccano la UI)
try
{
await entitiesTask;
Logger.LogInformation("Discovery dettagli SObject completata in background");
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Impossibile completare la discovery dettagli SObject (non critico)");
}
}
catch (Exception ex)
{
salesforceSourceErrorMessage = $"Errore di connessione: {ex.Message}";
Logger.LogError(ex, "Errore nella connessione a Salesforce source: {Credential}", selectedSalesforceSourceCredential);
currentSalesforceSourceClient = null;
currentSalesforceSourceDiscovery = null;
}
finally
{
isConnectingSalesforceSource = false;
StateHasChanged();
}
}
/// <summary>
/// Seleziona un SObject Salesforce come sorgente dati e carica i suoi campi
/// </summary>
protected async Task SelectSalesforceSourceEntity(RestEntitySummary entity)
{
selectedSalesforceSourceEntity = entity;
salesforceSourceEntityDetails = null;
// Carica i dettagli dei campi dell'SObject selezionato
if (currentSalesforceSourceDiscovery != null)
{
try
{
Logger.LogInformation("Caricamento dettagli SObject sorgente: {EntityName}", entity.Name);
salesforceSourceEntityDetails = await currentSalesforceSourceDiscovery.DiscoverEntityDetailsAsync(entity.Name);
Logger.LogInformation("Dettagli SObject caricati: {FieldCount} campi disponibili",
salesforceSourceEntityDetails?.Properties.Count ?? 0);
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore nel caricamento dettagli SObject {EntityName}", entity.Name);
salesforceSourceErrorMessage = $"Errore nel caricamento dei campi: {ex.Message}";
}
}
// Pulisce i mapping esistenti quando si cambia entità
ClearAllMappings();
StateHasChanged();
}
/// <summary>
/// Restituisce la lista filtrata degli SObject in base al termine di ricerca
/// </summary>
protected IEnumerable<RestEntitySummary> GetFilteredSalesforceSourceEntities()
{
if (string.IsNullOrEmpty(salesforceSourceSearchTerm))
return salesforceSourceEntities;
return salesforceSourceEntities
.Where(e => e.Name.Contains(salesforceSourceSearchTerm, StringComparison.OrdinalIgnoreCase) ||
(!string.IsNullOrEmpty(e.Label) && e.Label.Contains(salesforceSourceSearchTerm, StringComparison.OrdinalIgnoreCase)));
}
/// <summary>
/// Aggiorna il termine di ricerca per gli SObject sorgente
/// </summary>
protected void FilterSalesforceSourceEntities(ChangeEventArgs e)
{
salesforceSourceSearchTerm = e.Value?.ToString() ?? "";
StateHasChanged();
}
/// <summary>
/// Pulisce il termine di ricerca per gli SObject sorgente
/// </summary>
protected void ClearSalesforceSourceSearch()
{
salesforceSourceSearchTerm = "";
StateHasChanged();
}
/// <summary>
/// Estrae tutti i record dall'SObject Salesforce selezionato usando le mappature campi configurate
/// </summary>
protected async Task<IEnumerable<Dictionary<string, object>>> GetAllRecordsFromSalesforceSource()
{
if (currentSalesforceSourceClient == null || selectedSalesforceSourceEntity == null)
return new List<Dictionary<string, object>>();
if (!(currentSalesforceSourceClient is DataConnection.REST.Implementations.SalesforceServiceClient sfClient))
{
Logger.LogError("Il client Salesforce source non è un'istanza di SalesforceServiceClient");
return new List<Dictionary<string, object>>();
}
try
{
// Determina i campi da estrarre (solo quelli mappati + campo chiave)
var fieldsToExtract = new List<string>();
// Aggiungi i campi sorgente dal mapping
fieldsToExtract.AddRange(fieldMappings.Keys);
// Aggiungi il campo chiave sorgente se configurato
if (!string.IsNullOrEmpty(sourceKeyField) && !fieldsToExtract.Contains(sourceKeyField))
fieldsToExtract.Add(sourceKeyField);
// Aggiungi i campi usati nelle External ID Relationships (se presenti e destinazione è REST)
foreach (var rel in externalIdRelationships)
{
if (!string.IsNullOrEmpty(rel.SourceField) && !fieldsToExtract.Contains(rel.SourceField))
fieldsToExtract.Add(rel.SourceField);
}
// Se nessun campo è specificato, estrae tutto
var fields = fieldsToExtract.Any() ? fieldsToExtract : null;
Logger.LogInformation("Estrazione dati da Salesforce SObject: {EntityName}, Campi: {Fields}",
selectedSalesforceSourceEntity.Name,
fields != null ? string.Join(", ", fields) : "tutti");
var records = await sfClient.ExtractAllEntitiesAsync(
selectedSalesforceSourceEntity.Name,
fields);
Logger.LogInformation("Estratti {Count} record da Salesforce {EntityName}",
records.Count, selectedSalesforceSourceEntity.Name);
return records;
}
catch (Exception ex)
{
Logger.LogError(ex, "Errore nell'estrazione dati da Salesforce {EntityName}",
selectedSalesforceSourceEntity?.Name ?? "N/A");
throw;
}
}
}