6 Commits

Author SHA1 Message Date
Alessio Dal Santo 11ff67f24d [Feature] Campo nome obbligatorio nella form di creazione connessioni
Aggiunto attributo [Required] al campo Name nelle classi DTO DatabaseCredential
e RestApiCredential in CredentialManager/Models/CredentialModels.cs.

Modifiche:
- Aggiunto `using System.ComponentModel.DataAnnotations` al file
- DatabaseCredential.Name: [Required(ErrorMessage = "Il nome è obbligatorio")]
- RestApiCredential.Name: [Required(ErrorMessage = "Il nome è obbligatorio")]

Il DataAnnotationsValidator già presente nelle EditForm di CredentialManagement.razor
intercetta automaticamente il vincolo e impedisce la submit mostrando il messaggio
di errore inline quando il campo è vuoto, senza modifiche alla logica di salvataggio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:06:18 +02:00
Alessio Dal Santo 0df42e4259 [Cleanup] Rimossa vista FetchData con riferimenti a WeatherForecast 2026-05-28 11:53:08 +02:00
Alessio Dal Santo a81a868005 [Cleanup] Rimozione codice morto
Eliminati file e codice inutilizzati identificati durante l'analisi del codice morto:

File eliminati:
- Data_Coupler/Data/WeatherForecast.cs: classe demo del template Blazor, mai referenziata
- Data_Coupler/Data/WeatherForecastService.cs: servizio demo del template Blazor, mai iniettato
- DataConnection/CredentialManagement/Models/CredentialExtensions2.cs: file vuoto residuo di refactoring
- DataConnection/CredentialManagement/Models/CredentialExtensions_New.cs: file vuoto residuo di refactoring
- DataConnection/CredentialManagement/ServiceCollectionExtensions_New.cs: file vuoto residuo di refactoring
- CredentialManager/Services/KeyMappingService.cs: file vuoto senza implementazione
- DataConnection/DB/EF/ExistingDatabaseExample.cs: file vuoto di esempio non compilato

Interfaccia rimossa:
- DataConnection/CredentialManagement/ServiceCollectionExtensions.cs: rimossa IDataConnectionCredentialServiceConfiguration,
  interfaccia mai implementata né utilizzata in alcuna parte del codebase

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:29:10 +02:00
Alessio Dal Santo 344853fde9 [Feature/Perf] Ottimizzazioni bulk pre-discovery, batch deletion sync e supporto OLE DB / Salesforce client_credentials
## Bulk Pre-Discovery e riduzione query SQLite/SOQL

### KeyAssociationService — FindAssociationsByKeyValuesBulkAsync (nuovo)
- Aggiunta query bulk 'WHERE KeyValue IN (...)' per recuperare N associazioni con 1 sola query SQLite
  (chunking a 500 chiavi per rispettare il limite ~999 parametri di SQLite)
- Aggiunta interfaccia IKeyAssociationService e delegata in DataConnectionCredentialService / IDataConnectionCredentialService

### AssociationService — BatchFindOrCreateAssociationsAsync (nuovo)
- Nuovo metodo bulk che sostituisce i loop per-record durante l'analisi composite:
  1) 1 query SQLite bulk per tutte le chiavi
  2) Per le chiavi non trovate: SOQL 'IN (...)' su Salesforce in chunk da 200 via BatchExecuteQueriesAsync
     (ceil(K/25) HTTP Composite call invece di K singole)
  3) Salvataggio parallelo delle associazioni pre-discovery scoperte
- Fallback per-record automatico per client REST non Salesforce
- Aggiornata interfaccia IAssociationService con documentazione XML completa

### DataCoupler.razor.cs — STEP A/B nel flusso COMPOSITE
- Pre-Discovery spostata FUORI dal loop parallelo (STEP A, prima dell'analisi)
- associationsByKey pre-popolato con BatchFindOrCreateAssociationsAsync
- STEP B: il loop parallelo usa TryGetValue O(1) invece di query async per record
- Rimozione blocco ~40 righe di per-record lookup / fallback duplicati

## Salesforce Composite API — Batch Delete e Patch

### SalesforceServiceClient — metodi batch (nuovi)
- BatchDeleteEntitiesAsync: elimina N record con ceil(N/25) Composite call invece di N
- BatchPatchSingleFieldAsync: aggiorna un singolo campo su N record tramite BatchUpdateEntitiesAsync

### DeletionSyncService — refactoring batch
- ExecuteBatchedSalesforceDeletionsAsync: orchestrazione batch per Delete / Deactivate / Mark su Salesforce
- ExecuteSequentialDeletionsAsync: loop sequenziale esistente estratto in metodo riutilizzabile
- Dispatcher: Salesforce -> batch Composite, altri client REST -> sequenziale

## Supporto OLE DB (database)

### DatabaseSchemaProviderFactory
- Aggiunto case DatabaseType.OleDb -> new OleDbSchemaProvider() nel factory switch

### DatabaseMethod.cs
- Aggiunto metodo IsOleDbConnection() (parallelo a IsOdbcConnection())
- Query validation e manager temporaneo estesi a OLE DB oltre che ODBC
- GetLimitedQuery: aggiunto case OleDb -> 'SELECT TOP N FROM (subquery)'

## Salesforce OAuth2 — fix client_credentials

### CredentialService.cs
- Aggiunto 'GrantType' alla HashSet serviceSpecificKeys per preservarlo nella serializzazione AdditionalParameters

### DataConnectionCredentialService.cs
- Refactored BuildRestServiceOptions in helper statico riutilizzato da entrambi i metodi GetRestServiceOptions
- Mapping coerente ClientId/ClientSecret/GrantType per Salesforce (allineato a DataConnectionFactory)
- TestSalesforceOAuthLogin: branch esplicito per client_credentials (no username/password/token)
  con validazione preventiva ClientId+ClientSecret obbligatori
- Log flow label (password|client_credentials) in tutti i messaggi di autenticazione

## VS Code tasks

### .vscode/tasks.json
- Rimosso task generico 'Publish Data_Coupler'
- Aggiunti due task separati: win-x64 e win-x86, entrambi SingleFile + Self-Contained + ReadyToRun
2026-05-28 11:15:18 +02:00
Alessio 82e0d6bc77 [Feature] Aggiunto supporto completo OLE DB per connessione database
## Nuovi file
- DataConnection/DB/OleDbDatabaseManager.cs: Manager completo per connessioni OLE DB
  con supporto Task.Run() per operazioni sincrone, parametri posizionali '?',
  guard per piattaforma Windows, nessun supporto ChangeDatabaseAsync (no-op)
- DataConnection/DB/EF/SchemaProviders/OleDbSchemaProvider.cs: Schema provider per
  OLE DB, usa OleDbSchemaGuid per tabelle/colonne/chiavi primarie, mapping tipi dati
- CredentialManager/Services/OleDbProviderDiscoveryService.cs: Servizio di discovery
  provider OLE DB installati tramite registro Windows (HKEY_CLASSES_ROOT). Rileva
  9 provider noti: VFPOLEDB.1, Microsoft.ACE.OLEDB.12.0, Jet 4.0, SQLOLEDB, ecc.
  Mostra warning per provider solo 32-bit (VFPOLEDB, Jet)
- PUBLISH_32BIT_64BIT.md: Documentazione completa comandi pubblicazione per
  win-x64, win-x86 (richiesto per VFP), linux-x64, osx-x64, osx-arm64.
  Include prerequisiti VFPOLEDB, esempi connection string VFP, note Docker

## File modificati
- DataConnection/DB/Enums/DatabaseType.cs: Aggiunto valore OleDb dopo Odbc
- DataConnection/DataConnection.csproj: Aggiunto pacchetto System.Data.OleDb 9.0.3
- DataConnection/DB/OdbcDatabaseManager.cs: Fix bug ChangeDatabaseAsync con try-catch
- CredentialManager/Models/CredentialModels.cs: Aggiunto OleDb all'enum DatabaseType,
  BuildOleDbConnectionString() con supporto provider da AdditionalParameters,
  default VFPOLEDB.1, costruzione connection string con parametri VFP
- DataConnection/CredentialManagement/Models/CredentialExtensions.cs: Mappatura
  OleDb in ToDataConnectionDatabaseType() e ToCredentialDatabaseType()
- DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs:
  Aggiunto case OleDb in TestDatabaseConnectionAsync e metodo TestOleDbConnection()
  con apertura connessione via Task.Run() e gestione OleDbException dettagliata
- Data_Coupler/Services/DataConnectionFactory.cs: Aggiunto case OleDb per creazione
  OleDbDatabaseManager
- Data_Coupler/Program.cs: Registrazione IOleDbProviderDiscoveryService come Scoped
- Data_Coupler/Pages/CredentialManagement.razor: Aggiunta UI completa OLE DB con
  sezione dedicata Visual FoxPro (percorso .dbc/.dbf, Collating Sequence, DELETED),
  provider discovery con refresh, anteprima connection string, variabili di stato
  e metodi nel codice Blazor, sincronizzazione AdditionalParameters al salvataggio

## Compatibilità
- VFP 8.0/9.0: testato con VFPOLEDB.1, connessione file-based .dbc e .dbf
- Richiede pubblicazione win-x86 per driver OLE DB 32-bit
- AnyCPU non supportato per VFP (COM 32-bit)
2026-05-25 21:20:08 +02:00
Alessio 6452d45b77 [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()
2026-05-24 23:55:51 +02:00
40 changed files with 3323 additions and 367 deletions
+29 -7
View File
@@ -29,17 +29,39 @@
} }
}, },
{ {
"label": "Publish Data_Coupler", "label": "Publish Data_Coupler Temp SingleFile Self-Contained Ready-To-Run win-x64",
"detail": "Publish the Data Coupler 64-bit with a Single File, Self Contained, Ready-To-Run",
"type": "shell", "type": "shell",
"command": "dotnet", "command": "dotnet",
"args": [ "args": [
"publish", "publish",
"--configuration", "Data_Coupler/Data_Coupler.csproj",
"Release", "-c", "Release",
"--output", "-r", "win-x64",
"${workspaceFolder}/publish", "--self-contained", "true",
"--project", "-p:PublishSingleFile=true",
"Data_Coupler/Data_Coupler.csproj" "-p:PublishReadyToRun=true",
"-p:PublishTrimmed=false",
"-o", "C:\\Temp\\Publish\\Data_Coupler"
],
"group": "build",
"problemMatcher": []
},
{
"label": "Publish Data_Coupler Temp SingleFile Self-Contained Ready-To-Run win-x86",
"detail": "Publish the Data Coupler 32-bit with a Single File, Self Contained, Ready-To-Run",
"type": "shell",
"command": "dotnet",
"args": [
"publish",
"Data_Coupler/Data_Coupler.csproj",
"-c", "Release",
"-r", "win-x86",
"--self-contained", "true",
"-p:PublishSingleFile=true",
"-p:PublishReadyToRun=true",
"-p:PublishTrimmed=false",
"-o", "C:\\Temp\\Publish\\Data_Coupler_x86"
], ],
"group": "build", "group": "build",
"problemMatcher": [] "problemMatcher": []
+48 -1
View File
@@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace CredentialManager.Models; namespace CredentialManager.Models;
/// <summary> /// <summary>
@@ -55,7 +57,8 @@ public enum DatabaseType
Sqlite, Sqlite,
DB2, DB2,
SapHana, SapHana,
Odbc Odbc,
OleDb
} }
/// <summary> /// <summary>
@@ -79,6 +82,7 @@ public enum OdbcConnectionMode
/// </summary> /// </summary>
public class DatabaseCredential public class DatabaseCredential
{ {
[Required(ErrorMessage = "Il nome è obbligatorio")]
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public DatabaseType DatabaseType { get; set; } public DatabaseType DatabaseType { get; set; }
public string Host { get; set; } = string.Empty; public string Host { get; set; } = string.Empty;
@@ -101,6 +105,7 @@ public class DatabaseCredential
/// </summary> /// </summary>
public class RestApiCredential public class RestApiCredential
{ {
[Required(ErrorMessage = "Il nome è obbligatorio")]
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public RestServiceType ServiceType { get; set; } = RestServiceType.Generic; public RestServiceType ServiceType { get; set; } = RestServiceType.Generic;
public string BaseUrl { get; set; } = string.Empty; public string BaseUrl { get; set; } = string.Empty;
@@ -194,6 +199,7 @@ public static class ConnectionStringBuilder
DatabaseType.DB2 => BuildDb2ConnectionString(credential), DatabaseType.DB2 => BuildDb2ConnectionString(credential),
DatabaseType.SapHana => BuildSapHanaConnectionString(credential), DatabaseType.SapHana => BuildSapHanaConnectionString(credential),
DatabaseType.Odbc => BuildOdbcConnectionString(credential), DatabaseType.Odbc => BuildOdbcConnectionString(credential),
DatabaseType.OleDb => BuildOleDbConnectionString(credential),
_ => throw new NotSupportedException($"Database type {credential.DatabaseType} not supported") _ => throw new NotSupportedException($"Database type {credential.DatabaseType} not supported")
}; };
} private static string BuildSqlServerConnectionString(DatabaseCredential credential) } private static string BuildSqlServerConnectionString(DatabaseCredential credential)
@@ -427,6 +433,47 @@ public static class ConnectionStringBuilder
return string.Join(";", builder); return string.Join(";", builder);
} }
private static string BuildOleDbConnectionString(DatabaseCredential credential)
{
// Se è già presente una connection string personalizzata, utilizzala
if (!string.IsNullOrEmpty(credential.ConnectionString))
return credential.ConnectionString;
var builder = new List<string>();
// Provider OLE DB (obbligatorio)
var provider = credential.AdditionalParameters?.GetValueOrDefault("Provider") ?? "VFPOLEDB.1";
builder.Add($"Provider={provider}");
// Data Source: per VFP e Access è il percorso file/cartella
// DatabaseName è il campo principale (come per SQLite)
var dataSource = !string.IsNullOrEmpty(credential.DatabaseName)
? credential.DatabaseName
: credential.Host;
if (!string.IsNullOrEmpty(dataSource))
builder.Add($"Data Source={dataSource}");
// Credenziali (opzionali per VFP file-based)
if (!string.IsNullOrEmpty(credential.Username))
builder.Add($"User ID={credential.Username}");
if (!string.IsNullOrEmpty(credential.Password))
builder.Add($"Password={credential.Password}");
// Parametri aggiuntivi specifici (es. Collating Sequence, Exclusive, DELETED per VFP)
if (credential.AdditionalParameters != null)
{
foreach (var param in credential.AdditionalParameters)
{
if (param.Key != "Provider") // Provider già gestito sopra
builder.Add($"{param.Key}={param.Value}");
}
}
return string.Join(";", builder);
}
private static void AddAdditionalParameters(List<string> builder, Dictionary<string, string>? additionalParams) private static void AddAdditionalParameters(List<string> builder, Dictionary<string, string>? additionalParams)
{ {
if (additionalParams != null) if (additionalParams != null)
@@ -810,7 +810,7 @@ public class CredentialService : ICredentialService
{ {
"CompanyDatabase", "Language", "Version", "UseTrustedConnection", "CompanyDatabase", "Language", "Version", "UseTrustedConnection",
"SecurityToken", "ClientId", "ClientSecret", "ApiVersion", "SecurityToken", "ClientId", "ClientSecret", "ApiVersion",
"IsSandbox", "UseSoapApi", "RefreshToken", "AccessToken", "TokenExpiry" "IsSandbox", "UseSoapApi", "GrantType", "RefreshToken", "AccessToken", "TokenExpiry"
}; };
foreach (var param in additionalParams) foreach (var param in additionalParams)
@@ -112,6 +112,16 @@ public interface IKeyAssociationService
/// </summary> /// </summary>
Task<KeyAssociation?> FindAssociationByKeyValueParallelAsync(string keyValue); Task<KeyAssociation?> FindAssociationByKeyValueParallelAsync(string keyValue);
/// <summary>
/// Versione bulk: ricerca in un colpo solo tutte le associazioni attive per la combinazione
/// (KeyValue ∈ keyValues, DestinationEntity, RestCredentialName) usando una query SQL IN(...).
/// Riduce drasticamente le query SQLite quando si processano molti record.
/// </summary>
Task<Dictionary<string, KeyAssociation>> FindAssociationsByKeyValuesBulkAsync(
IEnumerable<string> keyValues,
string destinationEntity,
string restCredentialName);
/// <summary> /// <summary>
/// Versione thread-safe per operazioni parallele - Elimina associazione /// Versione thread-safe per operazioni parallele - Elimina associazione
/// </summary> /// </summary>
@@ -358,6 +358,63 @@ public class KeyAssociationService : IKeyAssociationService
} }
} }
/// <summary>
/// Bulk lookup delle associazioni: una sola query con WHERE KeyValue IN (...).
/// Per N chiavi sostituisce fino a 2N query SQLite del flusso per-record.
/// </summary>
public async Task<Dictionary<string, KeyAssociation>> FindAssociationsByKeyValuesBulkAsync(
IEnumerable<string> keyValues,
string destinationEntity,
string restCredentialName)
{
var distinctKeys = keyValues
.Where(k => !string.IsNullOrEmpty(k))
.Distinct()
.ToList();
if (distinctKeys.Count == 0)
return new Dictionary<string, KeyAssociation>(StringComparer.Ordinal);
try
{
// SQLite ha un limite hardcoded di ~999 parametri per query: chunk per sicurezza.
const int chunkSize = 500;
var result = new Dictionary<string, KeyAssociation>(StringComparer.Ordinal);
for (int i = 0; i < distinctKeys.Count; i += chunkSize)
{
var chunk = distinctKeys.Skip(i).Take(chunkSize).ToList();
var associations = await _context.KeyAssociations
.AsNoTracking()
.Where(ka => ka.IsActive &&
ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName &&
chunk.Contains(ka.KeyValue))
.ToListAsync();
// Se ci sono duplicati (KeyValue ripetuto), tieni il più recente
foreach (var assoc in associations
.GroupBy(a => a.KeyValue)
.Select(g => g.OrderByDescending(a => a.UpdatedAt ?? a.CreatedAt).First()))
{
result[assoc.KeyValue] = assoc;
}
}
_logger.LogDebug("BULK: Ricerca associazioni completata - {Found}/{Total} match per {Entity}/{Credential}",
result.Count, distinctKeys.Count, destinationEntity, restCredentialName);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "BULK: Errore nella ricerca bulk delle associazioni ({Count} chiavi, Entity={Entity})",
distinctKeys.Count, destinationEntity);
throw;
}
}
/// <summary> /// <summary>
/// Versione thread-safe per operazioni parallele - Delete association /// Versione thread-safe per operazioni parallele - Delete association
/// </summary> /// </summary>
@@ -0,0 +1,112 @@
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using System.Runtime.InteropServices;
namespace CredentialManager.Services;
/// <summary>
/// Informazioni su un provider OLE DB installato nel sistema
/// </summary>
public class OleDbProviderInfo
{
/// <summary>ProgID del provider (es. VFPOLEDB.1, Microsoft.ACE.OLEDB.12.0)</summary>
public string ProgId { get; set; } = string.Empty;
/// <summary>Descrizione leggibile del provider</summary>
public string Description { get; set; } = string.Empty;
/// <summary>Indica se è un provider Visual FoxPro (solo 32-bit)</summary>
public bool IsVfpProvider { get; set; }
/// <summary>Nota aggiuntiva (es. avviso 32-bit)</summary>
public string? Note { get; set; }
}
/// <summary>
/// Interfaccia per il servizio di discovery dei provider OLE DB installati
/// </summary>
public interface IOleDbProviderDiscoveryService
{
/// <summary>
/// Ottiene la lista dei provider OLE DB noti installati nel sistema
/// </summary>
List<OleDbProviderInfo> GetInstalledProviders();
/// <summary>
/// Verifica se almeno un provider Visual FoxPro è installato
/// </summary>
bool IsVfpProviderInstalled();
}
/// <summary>
/// Servizio per la discovery dei provider OLE DB installati tramite il registro di Windows.
/// Controlla un elenco di provider noti verificando la presenza della chiave HKEY_CLASSES_ROOT\{ProgId}.
/// </summary>
public class OleDbProviderDiscoveryService : IOleDbProviderDiscoveryService
{
private readonly ILogger<OleDbProviderDiscoveryService> _logger;
/// <summary>
/// Provider OLE DB noti: ProgID → Descrizione
/// </summary>
private static readonly (string ProgId, string Description, bool IsVfp)[] KnownProviders =
{
("VFPOLEDB.1", "Microsoft OLE DB Provider per Visual FoxPro 8.0/9.0 (32-bit)", true),
("VFPOLEDB", "Microsoft OLE DB Provider per Visual FoxPro (32-bit)", true),
("Microsoft.ACE.OLEDB.12.0", "Microsoft Access Database Engine 2010", false),
("Microsoft.ACE.OLEDB.16.0", "Microsoft Access Database Engine 2016", false),
("Microsoft.Jet.OLEDB.4.0", "Microsoft Jet 4.0 OLE DB Provider (Access/Excel 97-2003)", false),
("SQLOLEDB", "Microsoft OLE DB Provider for SQL Server (legacy)", false),
("SQLNCLI11", "SQL Server Native Client 11.0", false),
("MSOLEDBSQL", "Microsoft OLE DB Driver for SQL Server", false),
("MSDAORA", "Microsoft OLE DB Provider for Oracle (legacy)", false),
};
public OleDbProviderDiscoveryService(ILogger<OleDbProviderDiscoveryService> logger)
{
_logger = logger;
}
public List<OleDbProviderInfo> GetInstalledProviders()
{
var installed = new List<OleDbProviderInfo>();
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
_logger.LogWarning("OLE DB è supportato solo su Windows. Nessun provider restituito.");
return installed;
}
foreach (var (progId, description, isVfp) in KnownProviders)
{
try
{
using var key = Registry.ClassesRoot.OpenSubKey(progId);
if (key != null)
{
var note = isVfp ? "⚠ Solo 32-bit — pubblicare con --runtime win-x86" : null;
installed.Add(new OleDbProviderInfo
{
ProgId = progId,
Description = description,
IsVfpProvider = isVfp,
Note = note
});
_logger.LogDebug("Provider OLE DB trovato: {ProgId}", progId);
}
}
catch (Exception ex)
{
_logger.LogDebug("Errore nel verificare il provider {ProgId}: {Message}", progId, ex.Message);
}
}
_logger.LogInformation("Provider OLE DB installati trovati: {Count}", installed.Count);
return installed;
}
public bool IsVfpProviderInstalled()
{
return GetInstalledProviders().Any(p => p.IsVfpProvider);
}
}
@@ -85,6 +85,14 @@ public interface IDataConnectionCredentialService
Task<KeyAssociation?> FindKeyAssociationByValueParallelAsync(string keyValue); Task<KeyAssociation?> FindKeyAssociationByValueParallelAsync(string keyValue);
Task<bool> DeleteKeyAssociationParallelAsync(int id); Task<bool> DeleteKeyAssociationParallelAsync(int id);
/// <summary>
/// Bulk lookup associazioni - una sola query SQLite per N chiavi.
/// </summary>
Task<Dictionary<string, KeyAssociation>> FindKeyAssociationsByValuesBulkAsync(
IEnumerable<string> keyValues,
string destinationEntity,
string restCredentialName);
// Deletion synchronization operations // Deletion synchronization operations
Task<int> MarkDeletedAssociationsAsync(List<string> sourceKeyValues, string destinationEntity, string restCredentialName); Task<int> MarkDeletedAssociationsAsync(List<string> sourceKeyValues, string destinationEntity, string restCredentialName);
Task<List<KeyAssociation>> GetPendingDeletionsAsync(string destinationEntity, string restCredentialName); Task<List<KeyAssociation>> GetPendingDeletionsAsync(string destinationEntity, string restCredentialName);
@@ -22,6 +22,7 @@ public static class CredentialExtensions
CredentialManager.Models.DatabaseType.DB2 => DataConnection.Enums.DatabaseType.DB2, CredentialManager.Models.DatabaseType.DB2 => DataConnection.Enums.DatabaseType.DB2,
CredentialManager.Models.DatabaseType.SapHana => DataConnection.Enums.DatabaseType.SapHana, CredentialManager.Models.DatabaseType.SapHana => DataConnection.Enums.DatabaseType.SapHana,
CredentialManager.Models.DatabaseType.Odbc => DataConnection.Enums.DatabaseType.Odbc, CredentialManager.Models.DatabaseType.Odbc => DataConnection.Enums.DatabaseType.Odbc,
CredentialManager.Models.DatabaseType.OleDb => DataConnection.Enums.DatabaseType.OleDb,
_ => throw new NotSupportedException($"Database type {credentialDbType} not supported") _ => throw new NotSupportedException($"Database type {credentialDbType} not supported")
}; };
} }
@@ -41,6 +42,7 @@ public static class CredentialExtensions
DataConnection.Enums.DatabaseType.DB2 => CredentialManager.Models.DatabaseType.DB2, DataConnection.Enums.DatabaseType.DB2 => CredentialManager.Models.DatabaseType.DB2,
DataConnection.Enums.DatabaseType.SapHana => CredentialManager.Models.DatabaseType.SapHana, DataConnection.Enums.DatabaseType.SapHana => CredentialManager.Models.DatabaseType.SapHana,
DataConnection.Enums.DatabaseType.Odbc => CredentialManager.Models.DatabaseType.Odbc, DataConnection.Enums.DatabaseType.Odbc => CredentialManager.Models.DatabaseType.Odbc,
DataConnection.Enums.DatabaseType.OleDb => CredentialManager.Models.DatabaseType.OleDb,
_ => throw new NotSupportedException($"Database type {dataConnectionDbType} not supported") _ => throw new NotSupportedException($"Database type {dataConnectionDbType} not supported")
}; };
} }
@@ -84,23 +84,3 @@ public class DataConnectionCredentialOptions
/// </summary> /// </summary>
public int DatabaseTimeout { get; set; } = 30; public int DatabaseTimeout { get; set; } = 30;
} }
/// <summary>
/// Interfaccia per il servizio di gestione credenziali specifico per DataConnection
/// Questa interfaccia estende le funzionalità base di CredentialManager
/// con metodi specifici per l'integrazione con DataConnection
/// </summary>
public interface IDataConnectionCredentialServiceConfiguration
{
/// <summary>
/// Configura il servizio con le opzioni specificate
/// </summary>
/// <param name="options">Le opzioni di configurazione</param>
void Configure(DataConnectionCredentialOptions options);
/// <summary>
/// Verifica la connessione al database delle credenziali
/// </summary>
/// <returns>True se la connessione è valida</returns>
Task<bool> TestConnectionAsync();
}
@@ -168,16 +168,7 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
if (credential == null) if (credential == null)
throw new InvalidOperationException($"REST API credential '{credentialName}' not found"); throw new InvalidOperationException($"REST API credential '{credentialName}' not found");
var options = new DataConnection.REST.Configuration.RestServiceOptions var options = BuildRestServiceOptions(credential);
{
BaseUrl = credential.BaseUrl,
ApiKey = credential.ApiKey,
Username = credential.Username,
Password = credential.Password,
AuthToken = credential.AuthToken,
TimeoutSeconds = credential.TimeoutSeconds,
IgnoreSslErrors = credential.IgnoreSslErrors
};
_logger.LogDebug("Created RestServiceOptions for credential: {Name} ({BaseUrl})", _logger.LogDebug("Created RestServiceOptions for credential: {Name} ({BaseUrl})",
credentialName, credential.BaseUrl); credentialName, credential.BaseUrl);
@@ -191,19 +182,42 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
if (credential == null) if (credential == null)
throw new InvalidOperationException($"REST API credential with ID '{credentialId}' not found"); throw new InvalidOperationException($"REST API credential with ID '{credentialId}' not found");
var options = BuildRestServiceOptions(credential);
_logger.LogDebug("Created RestServiceOptions for credential ID: {Id} ({BaseUrl})",
credentialId, credential.BaseUrl);
return options;
}
private static DataConnection.REST.Configuration.RestServiceOptions BuildRestServiceOptions(RestApiCredential credential)
{
var options = new DataConnection.REST.Configuration.RestServiceOptions var options = new DataConnection.REST.Configuration.RestServiceOptions
{ {
BaseUrl = credential.BaseUrl, BaseUrl = credential.BaseUrl,
ApiKey = credential.ApiKey,
Username = credential.Username, Username = credential.Username,
Password = credential.Password, Password = credential.Password,
AuthToken = credential.AuthToken,
TimeoutSeconds = credential.TimeoutSeconds, TimeoutSeconds = credential.TimeoutSeconds,
IgnoreSslErrors = credential.IgnoreSslErrors IgnoreSslErrors = credential.IgnoreSslErrors
}; };
_logger.LogDebug("Created RestServiceOptions for credential ID: {Id} ({BaseUrl})", // Mapping coerente con DataConnectionFactory.CreateRestServiceClientAsync
credentialId, credential.BaseUrl); switch (credential.ServiceType)
{
case RestServiceType.Salesforce:
options.ApiKey = credential.ClientId;
options.AuthToken = credential.ClientSecret;
options.SalesforceGrantType = credential.GrantType;
break;
case RestServiceType.SapB1ServiceLayer:
options.ApiKey = credential.CompanyDatabase;
options.AuthToken = credential.AuthToken;
break;
default:
options.ApiKey = credential.ApiKey;
options.AuthToken = credential.AuthToken;
break;
}
return options; return options;
} }
@@ -251,6 +265,7 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
CredentialManager.Models.DatabaseType.Oracle => await TestOracleConnection(connectionString, credential), CredentialManager.Models.DatabaseType.Oracle => await TestOracleConnection(connectionString, credential),
CredentialManager.Models.DatabaseType.Sqlite => await TestSqliteConnection(connectionString, credential), CredentialManager.Models.DatabaseType.Sqlite => await TestSqliteConnection(connectionString, credential),
CredentialManager.Models.DatabaseType.Odbc => await TestOdbcConnection(connectionString, credential), CredentialManager.Models.DatabaseType.Odbc => await TestOdbcConnection(connectionString, credential),
CredentialManager.Models.DatabaseType.OleDb => await TestOleDbConnection(connectionString, credential),
_ => (false, $"Test di connessione non implementato per {credential.DatabaseType}") _ => (false, $"Test di connessione non implementato per {credential.DatabaseType}")
}; };
} }
@@ -404,6 +419,46 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
} }
} }
private async Task<(bool Success, string Message)> TestOleDbConnection(string connectionString, DatabaseCredential credential)
{
try
{
using var connection = new System.Data.OleDb.OleDbConnection(connectionString);
await Task.Run(() => connection.Open());
var details = new System.Text.StringBuilder();
details.AppendLine("Connessione OLE DB stabilita con successo!");
details.AppendLine();
details.AppendLine("Dettagli:");
details.AppendLine($"- Provider: {connection.Provider}");
if (!string.IsNullOrEmpty(connection.Database))
details.AppendLine($"- Database: {connection.Database}");
if (!string.IsNullOrEmpty(credential.DatabaseName))
details.AppendLine($"- Data Source: {credential.DatabaseName}");
details.AppendLine($"- Timeout: {credential.CommandTimeout}s");
return (true, details.ToString());
}
catch (System.Data.OleDb.OleDbException oleDbEx)
{
var errorDetails = new System.Text.StringBuilder();
errorDetails.AppendLine($"Errore OLE DB: {oleDbEx.Message}");
errorDetails.AppendLine();
errorDetails.AppendLine("Dettagli errori:");
foreach (System.Data.OleDb.OleDbError error in oleDbEx.Errors)
{
errorDetails.AppendLine($"- [{error.SQLState}] {error.Message}");
errorDetails.AppendLine($" Source: {error.Source}");
}
return (false, errorDetails.ToString());
}
catch (Exception ex)
{
return (false, $"Errore OLE DB: {ex.Message}");
}
}
public async Task<(bool Success, string Message)> TestRestApiConnectionAsync(string credentialName) public async Task<(bool Success, string Message)> TestRestApiConnectionAsync(string credentialName)
{ {
try try
@@ -509,8 +564,8 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
{ {
try try
{ {
_logger.LogInformation("Testing Salesforce authentication for {Name} ({BaseUrl})", _logger.LogInformation("Testing Salesforce authentication for {Name} ({BaseUrl}, GrantType={GrantType})",
credential.Name, credential.BaseUrl); credential.Name, credential.BaseUrl, credential.GrantType);
_logger.LogDebug("Salesforce credential details: Username={Username}, HasPassword={HasPassword}, HasSecurityToken={HasSecurityToken}, HasClientId={HasClientId}, HasClientSecret={HasClientSecret}", _logger.LogDebug("Salesforce credential details: Username={Username}, HasPassword={HasPassword}, HasSecurityToken={HasSecurityToken}, HasClientId={HasClientId}, HasClientSecret={HasClientSecret}",
credential.Username, !string.IsNullOrEmpty(credential.Password), !string.IsNullOrEmpty(credential.SecurityToken), credential.Username, !string.IsNullOrEmpty(credential.Password), !string.IsNullOrEmpty(credential.SecurityToken),
@@ -519,23 +574,42 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
using var httpClient = new HttpClient(); using var httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromSeconds(credential.TimeoutSeconds); httpClient.Timeout = TimeSpan.FromSeconds(credential.TimeoutSeconds);
// Test di autenticazione OAuth2
var tokenUrl = credential.BaseUrl.TrimEnd('/') + "/services/oauth2/token"; var tokenUrl = credential.BaseUrl.TrimEnd('/') + "/services/oauth2/token";
var tokenData = new List<KeyValuePair<string, string>> List<KeyValuePair<string, string>> tokenData;
{ string flowLabel;
new("grant_type", "password"),
new("username", credential.Username ?? "")
};
// Aggiungiamo password + security token se disponibile if (credential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials)
{
// Client Credentials flow — server-to-server, no user
if (string.IsNullOrEmpty(credential.ClientId) || string.IsNullOrEmpty(credential.ClientSecret))
{
return (false, "Flusso client_credentials richiede ClientId e ClientSecret configurati.");
}
tokenData = new List<KeyValuePair<string, string>>
{
new("grant_type", "client_credentials"),
new("client_id", credential.ClientId),
new("client_secret", credential.ClientSecret)
};
flowLabel = "client_credentials";
}
else
{
// Password flow (default)
var password = credential.Password ?? ""; var password = credential.Password ?? "";
if (!string.IsNullOrEmpty(credential.SecurityToken)) if (!string.IsNullOrEmpty(credential.SecurityToken))
{ {
password += credential.SecurityToken; password += credential.SecurityToken;
} }
tokenData.Add(new("password", password));
// Aggiungiamo client credentials se disponibili tokenData = new List<KeyValuePair<string, string>>
{
new("grant_type", "password"),
new("username", credential.Username ?? ""),
new("password", password)
};
if (!string.IsNullOrEmpty(credential.ClientId)) if (!string.IsNullOrEmpty(credential.ClientId))
{ {
tokenData.Add(new("client_id", credential.ClientId)); tokenData.Add(new("client_id", credential.ClientId));
@@ -544,24 +618,25 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
{ {
tokenData.Add(new("client_secret", credential.ClientSecret)); tokenData.Add(new("client_secret", credential.ClientSecret));
} }
flowLabel = "password";
}
_logger.LogDebug("Posting to Salesforce token URL: {TokenUrl}", tokenUrl); _logger.LogDebug("Posting to Salesforce token URL: {TokenUrl} (flow={Flow})", tokenUrl, flowLabel);
var tokenContent = new FormUrlEncodedContent(tokenData); var tokenContent = new FormUrlEncodedContent(tokenData);
var response = await httpClient.PostAsync(tokenUrl, tokenContent); var response = await httpClient.PostAsync(tokenUrl, tokenContent);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var responseContent = await response.Content.ReadAsStringAsync(); _logger.LogInformation("Salesforce authentication ({Flow}) successful for {Name}", flowLabel, credential.Name);
_logger.LogInformation("Salesforce authentication successful for {Name}", credential.Name); return (true, $"Autenticazione Salesforce riuscita!\n\nDettagli:\n- Login URL: {credential.BaseUrl}\n- API Version: {credential.ApiVersion}\n- Sandbox: {credential.IsSandbox}\n- Tipo Auth: OAuth2 ({flowLabel})\n- Timeout: {credential.TimeoutSeconds}s");
return (true, $"Autenticazione Salesforce riuscita!\n\nDettagli:\n- Login URL: {credential.BaseUrl}\n- API Version: {credential.ApiVersion}\n- Sandbox: {credential.IsSandbox}\n- Tipo Auth: OAuth2\n- Timeout: {credential.TimeoutSeconds}s");
} }
else else
{ {
var errorContent = await response.Content.ReadAsStringAsync(); var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Salesforce authentication failed for {Name}. Status: {StatusCode}, Response: {Response}", _logger.LogWarning("Salesforce authentication ({Flow}) failed for {Name}. Status: {StatusCode}, Response: {Response}",
credential.Name, response.StatusCode, errorContent); flowLabel, credential.Name, response.StatusCode, errorContent);
return (false, $"Autenticazione Salesforce fallita. Status: {response.StatusCode}\nDettagli: {errorContent}"); return (false, $"Autenticazione Salesforce ({flowLabel}) fallita. Status: {response.StatusCode}\nDettagli: {errorContent}");
} }
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
@@ -1033,6 +1108,14 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
return await _keyAssociationService.FindAssociationByKeyValueParallelAsync(keyValue); return await _keyAssociationService.FindAssociationByKeyValueParallelAsync(keyValue);
} }
public async Task<Dictionary<string, KeyAssociation>> FindKeyAssociationsByValuesBulkAsync(
IEnumerable<string> keyValues,
string destinationEntity,
string restCredentialName)
{
return await _keyAssociationService.FindAssociationsByKeyValuesBulkAsync(keyValues, destinationEntity, restCredentialName);
}
public async Task<bool> DeleteKeyAssociationParallelAsync(int id) public async Task<bool> DeleteKeyAssociationParallelAsync(int id)
{ {
return await _keyAssociationService.DeleteAssociationParallelAsync(id); return await _keyAssociationService.DeleteAssociationParallelAsync(id);
@@ -19,7 +19,10 @@ public class DatabaseSchemaProviderFactory
{ {
return databaseType switch return databaseType switch
{ {
DatabaseType.SqlServer => new SqlServerSchemaProvider(), DatabaseType.Odbc => new OdbcSchemaProvider(), // Aggiungere qui altri provider quando implementati DatabaseType.SqlServer => new SqlServerSchemaProvider(),
DatabaseType.Odbc => new OdbcSchemaProvider(),
DatabaseType.OleDb => new OleDbSchemaProvider(),
// Aggiungere qui altri provider quando implementati
// DatabaseType.MySql => new MySqlSchemaProvider(), // DatabaseType.MySql => new MySqlSchemaProvider(),
// DatabaseType.PostgreSql => new PostgreSqlSchemaProvider(), // DatabaseType.PostgreSql => new PostgreSqlSchemaProvider(),
// DatabaseType.Oracle => new OracleSchemaProvider(), // DatabaseType.Oracle => new OracleSchemaProvider(),
@@ -579,4 +579,94 @@ public class EFCoreDatabaseManager : IDatabaseManager
return null; return null;
} }
} }
/// <inheritdoc />
public async Task<bool> UpsertRecordAsync(string tableName, string keyField, object? keyValue, Dictionary<string, object?> record)
{
try
{
if (_context.Database.GetDbConnection().State != ConnectionState.Open)
await _context.Database.OpenConnectionAsync();
var connection = _context.Database.GetDbConnection();
// Determina il riferimento alla tabella (con o senza schema)
string tableRef;
if (tableName.Contains('.'))
{
var parts = tableName.Split('.', 2);
tableRef = $"[{parts[0]}].[{parts[1]}]";
}
else
{
tableRef = $"[{tableName}]";
}
// Controlla se il record esiste già
using var checkCmd = connection.CreateCommand();
checkCmd.CommandText = $"SELECT COUNT(*) FROM {tableRef} WHERE [{keyField}] = @p0";
var checkParam = checkCmd.CreateParameter();
checkParam.ParameterName = "@p0";
checkParam.Value = keyValue ?? DBNull.Value;
checkCmd.Parameters.Add(checkParam);
var countResult = await checkCmd.ExecuteScalarAsync();
bool exists = Convert.ToInt64(countResult ?? 0L) > 0;
if (exists)
{
// UPDATE
var fields = record.Keys.ToList();
var setClauses = fields.Select((f, i) => $"[{f}] = @p{i}").ToList();
var updateSql = $"UPDATE {tableRef} SET {string.Join(", ", setClauses)} WHERE [{keyField}] = @p{setClauses.Count}";
using var updateCmd = connection.CreateCommand();
updateCmd.CommandText = updateSql;
for (int i = 0; i < fields.Count; i++)
{
var p = updateCmd.CreateParameter();
p.ParameterName = $"@p{i}";
p.Value = record[fields[i]] ?? DBNull.Value;
updateCmd.Parameters.Add(p);
}
// Aggiunge il parametro per la WHERE
var keyParam = updateCmd.CreateParameter();
keyParam.ParameterName = $"@p{fields.Count}";
keyParam.Value = keyValue ?? DBNull.Value;
updateCmd.Parameters.Add(keyParam);
await updateCmd.ExecuteNonQueryAsync();
}
else
{
// INSERT
var fields = record.Keys.ToList();
var fieldNames = string.Join(", ", fields.Select(f => $"[{f}]"));
var paramPlaceholders = string.Join(", ", fields.Select((_, i) => $"@p{i}"));
var insertSql = $"INSERT INTO {tableRef} ({fieldNames}) VALUES ({paramPlaceholders})";
using var insertCmd = connection.CreateCommand();
insertCmd.CommandText = insertSql;
for (int i = 0; i < fields.Count; i++)
{
var p = insertCmd.CreateParameter();
p.ParameterName = $"@p{i}";
p.Value = record[fields[i]] ?? DBNull.Value;
insertCmd.Parameters.Add(p);
}
await insertCmd.ExecuteNonQueryAsync();
}
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Errore nell'upsert in {tableName}: {ex.Message}");
return false;
}
}
} }
@@ -0,0 +1,335 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Data.OleDb;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using DataConnection.Interfaces;
namespace DataConnection.EF.SchemaProviders;
/// <summary>
/// Provider di schema per database OLE DB (incluso Visual FoxPro)
/// Utilizza GetOleDbSchemaTable per ottenere metadati in modo compatibile con VFP e altri provider OLE DB
/// </summary>
public class OleDbSchemaProvider : IDatabaseSchemaProvider
{
public async Task<IDictionary<string, IEnumerable<DbColumnInfo>>> GetDatabaseSchemaAsync(string connectionString)
{
var result = new Dictionary<string, IEnumerable<DbColumnInfo>>();
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
throw new PlatformNotSupportedException("OLE DB è supportato solo su Windows.");
try
{
using var connection = new OleDbConnection(connectionString);
await Task.Run(() => connection.Open());
Console.WriteLine($"OLE DB Schema Provider - Provider: {connection.Provider}");
var tableNames = GetTableNamesFromConnection(connection);
Console.WriteLine($"Trovate {tableNames.Count} tabelle");
foreach (var tableName in tableNames)
{
try
{
var columns = GetTableColumnsFromConnection(connection, tableName);
if (columns.Any())
{
result[tableName] = columns;
Console.WriteLine($"Tabella {tableName}: {columns.Count()} colonne");
}
}
catch (Exception ex)
{
Console.WriteLine($"Errore nel leggere le colonne della tabella {tableName}: {ex.Message}");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Errore in OleDbSchemaProvider.GetDatabaseSchemaAsync: {ex.Message}");
throw;
}
return result;
}
public async Task<IEnumerable<string>> GetTableNamesAsync(string connectionString)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return Enumerable.Empty<string>();
try
{
using var connection = new OleDbConnection(connectionString);
await Task.Run(() => connection.Open());
return GetTableNamesFromConnection(connection);
}
catch (Exception ex)
{
Console.WriteLine($"Errore in OleDbSchemaProvider.GetTableNamesAsync: {ex.Message}");
return Enumerable.Empty<string>();
}
}
public async Task<IEnumerable<DbColumnInfo>> GetTableSchemaAsync(string connectionString, string tableName)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return Enumerable.Empty<DbColumnInfo>();
try
{
using var connection = new OleDbConnection(connectionString);
await Task.Run(() => connection.Open());
return GetTableColumnsFromConnection(connection, tableName);
}
catch (Exception ex)
{
Console.WriteLine($"Errore in OleDbSchemaProvider.GetTableSchemaAsync per {tableName}: {ex.Message}");
return Enumerable.Empty<DbColumnInfo>();
}
}
public async Task<IEnumerable<string>> GetAvailableDatabasesAsync(string connectionString)
{
// OLE DB file-based (VFP, Access) non supporta listing di database multipli
try
{
using var connection = new OleDbConnection(connectionString);
await Task.Run(() => connection.Open());
var db = connection.Database;
return string.IsNullOrEmpty(db) ? Enumerable.Empty<string>() : new[] { db };
}
catch
{
return Enumerable.Empty<string>();
}
}
private static List<string> GetTableNamesFromConnection(OleDbConnection connection)
{
var tableNames = new List<string>();
try
{
// Usa GetOleDbSchemaTable - più compatibile con VFP rispetto a GetSchema()
var restrictions = new object?[] { null, null, null, "TABLE" };
var tablesSchema = connection.GetOleDbSchemaTable(OleDbSchemaGuid.Tables, restrictions);
if (tablesSchema != null)
{
foreach (DataRow row in tablesSchema.Rows)
{
var tableName = row["TABLE_NAME"]?.ToString();
if (!string.IsNullOrEmpty(tableName))
tableNames.Add(tableName);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"GetOleDbSchemaTable Tables fallito, tentativo con GetSchema: {ex.Message}");
// Fallback a GetSchema per provider che non supportano GetOleDbSchemaTable
try
{
var tablesSchema = connection.GetSchema("Tables");
tableNames = tablesSchema.AsEnumerable()
.Where(row =>
{
var t = row["TABLE_TYPE"]?.ToString();
return t == "TABLE" || t == "BASE TABLE";
})
.Select(row => row["TABLE_NAME"]?.ToString() ?? string.Empty)
.Where(n => !string.IsNullOrEmpty(n))
.ToList();
}
catch (Exception ex2)
{
Console.WriteLine($"GetSchema Tables fallito anche: {ex2.Message}");
}
}
return tableNames.OrderBy(t => t).ToList();
}
private static List<DbColumnInfo> GetTableColumnsFromConnection(OleDbConnection connection, string tableName)
{
var columns = new List<DbColumnInfo>();
try
{
// Ottieni primary keys
var primaryKeys = GetPrimaryKeys(connection, tableName);
// Ottieni colonne via GetOleDbSchemaTable
var restrictions = new object?[] { null, null, tableName, null };
var columnsSchema = connection.GetOleDbSchemaTable(OleDbSchemaGuid.Columns, restrictions);
if (columnsSchema == null)
return columns;
// Ordina per posizione ordinale
var rows = columnsSchema.AsEnumerable()
.OrderBy(r => r.IsNull("ORDINAL_POSITION") ? 0 : Convert.ToInt32(r["ORDINAL_POSITION"]))
.ToList();
foreach (DataRow row in rows)
{
var columnName = row["COLUMN_NAME"]?.ToString();
if (string.IsNullOrEmpty(columnName))
continue;
// DATA_TYPE è un int (OleDbType enum value)
int oleDbTypeInt = row.IsNull("DATA_TYPE") ? 0 : Convert.ToInt32(row["DATA_TYPE"]);
var dataType = MapOleDbTypeToString(oleDbTypeInt);
// Formato con dimensioni
var columnSize = row.IsNull("CHARACTER_MAXIMUM_LENGTH") ? 0 : Convert.ToInt32(row["CHARACTER_MAXIMUM_LENGTH"]);
var numericPrecision = row.IsNull("NUMERIC_PRECISION") ? 0 : Convert.ToInt32(row["NUMERIC_PRECISION"]);
var numericScale = row.IsNull("NUMERIC_SCALE") ? 0 : Convert.ToInt32(row["NUMERIC_SCALE"]);
var formattedType = FormatDataType(dataType, columnSize, numericPrecision, numericScale);
bool isNullable = true;
if (!row.IsNull("IS_NULLABLE"))
isNullable = Convert.ToBoolean(row["IS_NULLABLE"]);
columns.Add(new DbColumnInfo
{
Name = columnName,
DataType = formattedType,
IsNullable = isNullable,
IsPrimaryKey = primaryKeys.Contains(columnName, StringComparer.OrdinalIgnoreCase)
});
}
}
catch (Exception ex)
{
Console.WriteLine($"Errore nel recuperare le colonne per {tableName}: {ex.Message}");
}
return columns;
}
private static HashSet<string> GetPrimaryKeys(OleDbConnection connection, string tableName)
{
var primaryKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
var restrictions = new object?[] { null, null, tableName };
var pkSchema = connection.GetOleDbSchemaTable(OleDbSchemaGuid.Primary_Keys, restrictions);
if (pkSchema != null)
{
foreach (DataRow row in pkSchema.Rows)
{
var col = row["COLUMN_NAME"]?.ToString();
if (!string.IsNullOrEmpty(col))
primaryKeys.Add(col);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Primary keys non disponibili per {tableName}: {ex.Message}");
// Fallback: prova con Indexes
try
{
var restrictions = new object?[] { null, null, null, null, tableName };
var idxSchema = connection.GetOleDbSchemaTable(OleDbSchemaGuid.Indexes, restrictions);
if (idxSchema != null)
{
foreach (DataRow row in idxSchema.Rows)
{
if (row["PRIMARY_KEY"] is bool pk && pk)
{
var col = row["COLUMN_NAME"]?.ToString();
if (!string.IsNullOrEmpty(col))
primaryKeys.Add(col);
}
}
}
}
catch { /* Se anche questo fallisce, ignora */ }
}
return primaryKeys;
}
private static string MapOleDbTypeToString(int oleDbTypeInt)
{
// Mappa OleDbType enum (int) a nome leggibile
// https://learn.microsoft.com/en-us/dotnet/api/system.data.oledb.oledbtype
return oleDbTypeInt switch
{
2 => "SmallInt",
3 => "Integer",
4 => "Single",
5 => "Double",
6 => "Currency",
7 => "Date",
8 => "VarChar", // BSTR
9 => "IDispatch",
10 => "Error",
11 => "Boolean",
12 => "Variant",
13 => "IUnknown",
14 => "Decimal",
16 => "TinyInt",
17 => "UnsignedTinyInt",
18 => "UnsignedSmallInt",
19 => "UnsignedInt",
20 => "BigInt",
21 => "UnsignedBigInt",
64 => "DateTime",
65 => "FileTime",
72 => "Guid",
128 => "Binary",
129 => "Char",
130 => "NVarChar", // WChar
131 => "Decimal", // Numeric
132 => "UserDefined",
133 => "Date",
134 => "Time",
135 => "DateTime", // DBTimeStamp
136 => "Variant", // Chapter
138 => "PropVariant",
139 => "VarNumeric",
200 => "VarChar",
201 => "LongVarChar",
202 => "NVarChar", // VarWChar
203 => "NText", // LongVarWChar
204 => "VarBinary",
205 => "Image", // LongVarBinary
_ => $"Type({oleDbTypeInt})"
};
}
private static string FormatDataType(string dataType, int columnSize, int numericPrecision, int numericScale)
{
var upper = dataType.ToUpperInvariant();
if (upper.Contains("DECIMAL") || upper.Contains("NUMERIC"))
{
if (numericPrecision > 0)
return $"{dataType}({numericPrecision},{numericScale})";
}
else if (upper.Contains("CHAR") || upper.Contains("VARCHAR") || upper.Contains("TEXT") || upper.Contains("BINARY"))
{
if (columnSize > 0 && columnSize < 8000)
return $"{dataType}({columnSize})";
else if (columnSize >= 8000)
return $"{dataType}(MAX)";
}
return dataType;
}
}
+2 -1
View File
@@ -12,5 +12,6 @@ public enum DatabaseType
Sqlite, Sqlite,
DB2, DB2,
SapHana, SapHana,
Odbc Odbc,
OleDb
} }
@@ -85,6 +85,18 @@ public interface IDatabaseManager : IDisposable
/// Ottiene il nome del campo Primary Key di una tabella specifica /// Ottiene il nome del campo Primary Key di una tabella specifica
/// </summary> /// </summary>
Task<string?> GetPrimaryKeyFieldAsync(string tableName); Task<string?> GetPrimaryKeyFieldAsync(string tableName);
/// <summary>
/// Esegue un upsert (INSERT o UPDATE) di un singolo record nella tabella specificata.
/// Se un record con lo stesso valore del campo chiave esiste già, viene aggiornato;
/// altrimenti viene inserito un nuovo record.
/// </summary>
/// <param name="tableName">Nome della tabella di destinazione</param>
/// <param name="keyField">Campo chiave per determinare se il record esiste</param>
/// <param name="keyValue">Valore del campo chiave del record</param>
/// <param name="record">Campi e valori da inserire/aggiornare</param>
/// <returns>True se l'operazione è riuscita, false altrimenti</returns>
Task<bool> UpsertRecordAsync(string tableName, string keyField, object? keyValue, Dictionary<string, object?> record);
} }
/// <summary> /// <summary>
+66 -1
View File
@@ -66,12 +66,20 @@ public class OdbcDatabaseManager : IDatabaseManager
using var connection = new OdbcConnection(_connectionString); using var connection = new OdbcConnection(_connectionString);
await connection.OpenAsync(); await connection.OpenAsync();
// Cambia database se specificato // Cambia database se specificato (alcuni driver come VFP non supportano ChangeDatabaseAsync)
if (!string.IsNullOrEmpty(databaseName) && databaseName != _currentDatabase) if (!string.IsNullOrEmpty(databaseName) && databaseName != _currentDatabase)
{
try
{ {
await connection.ChangeDatabaseAsync(databaseName); await connection.ChangeDatabaseAsync(databaseName);
_currentDatabase = databaseName; _currentDatabase = databaseName;
} }
catch (Exception dbChangeEx)
{
Console.WriteLine($"[ODBC] ChangeDatabaseAsync non supportato dal driver ({databaseName}): {dbChangeEx.Message}");
// Continua senza cambiare database (es. driver file-based come VFP)
}
}
using var command = new OdbcCommand(sql, connection); using var command = new OdbcCommand(sql, connection);
@@ -350,4 +358,61 @@ public class OdbcDatabaseManager : IDatabaseManager
{ {
// Nessuna risorsa da rilasciare per ODBC diretto // Nessuna risorsa da rilasciare per ODBC diretto
} }
/// <inheritdoc />
public async Task<bool> UpsertRecordAsync(string tableName, string keyField, object? keyValue, Dictionary<string, object?> record)
{
try
{
using var connection = new OdbcConnection(_connectionString);
await connection.OpenAsync();
// Controlla se il record esiste già (ODBC usa ? come placeholder)
using var checkCmd = new OdbcCommand($"SELECT COUNT(*) FROM {tableName} WHERE [{keyField}] = ?", connection);
checkCmd.Parameters.Add(new OdbcParameter { Value = keyValue ?? DBNull.Value });
var countResult = await checkCmd.ExecuteScalarAsync();
bool exists = Convert.ToInt64(countResult ?? 0L) > 0;
if (exists)
{
// UPDATE
var fields = record.Keys.ToList();
var setClauses = fields.Select(f => $"[{f}] = ?").ToList();
var updateSql = $"UPDATE {tableName} SET {string.Join(", ", setClauses)} WHERE [{keyField}] = ?";
using var updateCmd = new OdbcCommand(updateSql, connection);
foreach (var f in fields)
updateCmd.Parameters.Add(new OdbcParameter { Value = record[f] ?? DBNull.Value });
// Parametro per la WHERE
updateCmd.Parameters.Add(new OdbcParameter { Value = keyValue ?? DBNull.Value });
await updateCmd.ExecuteNonQueryAsync();
}
else
{
// INSERT
var fields = record.Keys.ToList();
var fieldNames = string.Join(", ", fields.Select(f => $"[{f}]"));
var paramPlaceholders = string.Join(", ", fields.Select(_ => "?"));
var insertSql = $"INSERT INTO {tableName} ({fieldNames}) VALUES ({paramPlaceholders})";
using var insertCmd = new OdbcCommand(insertSql, connection);
foreach (var f in fields)
insertCmd.Parameters.Add(new OdbcParameter { Value = record[f] ?? DBNull.Value });
await insertCmd.ExecuteNonQueryAsync();
}
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Errore nell'upsert ODBC in {tableName}: {ex.Message}");
return false;
}
}
} }
+379
View File
@@ -0,0 +1,379 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Data.OleDb;
using System.Linq;
using System.Linq.Expressions;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using DataConnection.EF.SchemaProviders;
using DataConnection.Interfaces;
namespace DataConnection.DB;
/// <summary>
/// Database manager per connessioni OLE DB dirette (es. Visual FoxPro, Access, Jet)
/// Nota: i driver OLE DB come VFPOLEDB.1 sono 32-bit only — pubblicare con --runtime win-x86 se necessario
/// </summary>
public class OleDbDatabaseManager : IDatabaseManager
{
private readonly string _connectionString;
private readonly OleDbSchemaProvider _schemaProvider;
public OleDbDatabaseManager(string connectionString)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
throw new PlatformNotSupportedException("OLE DB è supportato solo su Windows.");
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_schemaProvider = new OleDbSchemaProvider();
}
public async Task<bool> TestConnectionAsync()
{
try
{
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
return true;
}
catch
{
return false;
}
}
public Task<IEnumerable<T>> GetAsync<T>(
Expression<Func<T, bool>>? filter = null,
Func<IQueryable<T>, IOrderedQueryable<T>>? orderBy = null,
string includeProperties = "",
int? skip = null,
int? take = null) where T : class
{
throw new NotSupportedException("GetAsync<T> con espressioni LINQ non è supportato per OLE DB. Usare ExecuteRawQueryAsync.");
}
public Task<T?> GetByIdAsync<T>(object id) where T : class
{
throw new NotSupportedException("GetByIdAsync<T> non è supportato per OLE DB. Usare ExecuteRawQueryAsync con clausola WHERE.");
}
public Task<IEnumerable<T>> ExecuteQueryAsync<T>(string sql, params object[] parameters) where T : class
{
throw new NotSupportedException("ExecuteQueryAsync<T> non è supportato per OLE DB. Usare ExecuteRawQueryAsync.");
}
public async Task<List<Dictionary<string, object>>> ExecuteRawQueryAsync(string sql, string databaseName = "", params object[] parameters)
{
var results = new List<Dictionary<string, object>>();
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
// OLE DB file-based (VFP, Access) non supporta ChangeDatabaseAsync — il database è nel Data Source
// Ignoriamo databaseName per questa tipologia di provider
using var command = new OleDbCommand(sql, connection);
if (parameters != null && parameters.Length > 0)
{
for (int i = 0; i < parameters.Length; i++)
{
command.Parameters.Add(new OleDbParameter($"@p{i}", parameters[i] ?? DBNull.Value));
}
}
using var reader = await Task.Run(() => command.ExecuteReader());
while (reader.Read())
{
var row = new Dictionary<string, object>();
for (int i = 0; i < reader.FieldCount; i++)
{
var fieldName = reader.GetName(i);
var value = reader.IsDBNull(i) ? DBNull.Value : reader.GetValue(i);
row[fieldName] = value;
}
results.Add(row);
}
return results;
}
public async Task<int> ExecuteCommandAsync(string sql, params object[] parameters)
{
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var command = new OleDbCommand(sql, connection);
if (parameters != null && parameters.Length > 0)
{
for (int i = 0; i < parameters.Length; i++)
{
command.Parameters.Add(new OleDbParameter($"@p{i}", parameters[i] ?? DBNull.Value));
}
}
return await Task.Run(() => command.ExecuteNonQuery());
}
public async Task<List<string>> GetAvailableDatabasesAsync()
{
var databases = await _schemaProvider.GetAvailableDatabasesAsync(_connectionString);
return databases.ToList();
}
public async Task ChangeDatabaseAsync(string databaseName)
{
// I provider OLE DB file-based (VFP, Access) non supportano il cambio di database a runtime
// Il Data Source nella connection string definisce il database
Console.WriteLine($"[OleDb] ChangeDatabaseAsync ignorato per provider file-based (database: {databaseName})");
await Task.CompletedTask;
}
public async Task<IDictionary<string, IEnumerable<DbColumnInfo>>> GetDatabaseSchemaAsync()
{
return await _schemaProvider.GetDatabaseSchemaAsync(_connectionString);
}
public async Task<IEnumerable<string>> GetTableNamesAsync()
{
return await _schemaProvider.GetTableNamesAsync(_connectionString);
}
public async Task<IEnumerable<DbColumnInfo>> GetTableSchemaAsync(string tableName)
{
return await _schemaProvider.GetTableSchemaAsync(_connectionString, tableName);
}
public async Task<IEnumerable<Dictionary<string, object>>> GetAllRecordsAsync(string tableName)
{
var query = $"SELECT * FROM {tableName}";
return await ExecuteRawQueryAsync(query);
}
public async Task<string?> GetPrimaryKeyFieldAsync(string tableName)
{
try
{
var schema = await GetTableSchemaAsync(tableName);
var pkColumn = schema.FirstOrDefault(c => c.IsPrimaryKey);
return pkColumn?.Name;
}
catch
{
return null;
}
}
public async Task<IEnumerable<IDictionary<string, object?>>> ExecuteQueryAsync(string query, int? maxRows = null)
{
var results = new List<IDictionary<string, object?>>();
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
var commandText = maxRows.HasValue ? WrapQueryWithLimit(query, maxRows.Value) : query;
using var command = new OleDbCommand(commandText, connection);
using var reader = await Task.Run(() => command.ExecuteReader());
while (reader.Read())
{
var row = new Dictionary<string, object?>();
for (int i = 0; i < reader.FieldCount; i++)
{
var fieldName = reader.GetName(i);
var value = reader.IsDBNull(i) ? null : reader.GetValue(i);
row[fieldName] = value;
}
results.Add(row);
}
return results;
}
public async Task<int> ExecuteNonQueryAsync(string query)
{
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var command = new OleDbCommand(query, connection);
return await Task.Run(() => command.ExecuteNonQuery());
}
public async Task<object?> ExecuteScalarAsync(string query)
{
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var command = new OleDbCommand(query, connection);
return await Task.Run(() => command.ExecuteScalar());
}
public async Task<int> InsertAsync(string tableName, IDictionary<string, object?> data)
{
var columns = string.Join(", ", data.Keys.Select(k => $"[{k}]"));
var parameters = string.Join(", ", data.Keys.Select(_ => "?"));
var query = $"INSERT INTO {tableName} ({columns}) VALUES ({parameters})";
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var command = new OleDbCommand(query, connection);
foreach (var value in data.Values)
command.Parameters.Add(new OleDbParameter { Value = value ?? DBNull.Value });
return await Task.Run(() => command.ExecuteNonQuery());
}
public async Task<int> UpdateAsync(string tableName, IDictionary<string, object?> data, IDictionary<string, object?> whereClause)
{
var setClause = string.Join(", ", data.Keys.Select(k => $"[{k}] = ?"));
var whereConditions = string.Join(" AND ", whereClause.Keys.Select(k => $"[{k}] = ?"));
var query = $"UPDATE {tableName} SET {setClause} WHERE {whereConditions}";
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var command = new OleDbCommand(query, connection);
foreach (var value in data.Values)
command.Parameters.Add(new OleDbParameter { Value = value ?? DBNull.Value });
foreach (var value in whereClause.Values)
command.Parameters.Add(new OleDbParameter { Value = value ?? DBNull.Value });
return await Task.Run(() => command.ExecuteNonQuery());
}
public async Task<int> DeleteAsync(string tableName, IDictionary<string, object?> whereClause)
{
var whereConditions = string.Join(" AND ", whereClause.Keys.Select(k => $"[{k}] = ?"));
var query = $"DELETE FROM {tableName} WHERE {whereConditions}";
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var command = new OleDbCommand(query, connection);
foreach (var value in whereClause.Values)
command.Parameters.Add(new OleDbParameter { Value = value ?? DBNull.Value });
return await Task.Run(() => command.ExecuteNonQuery());
}
public async Task<int> BulkInsertAsync(string tableName, IEnumerable<IDictionary<string, object?>> dataList)
{
int totalInserted = 0;
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var transaction = connection.BeginTransaction();
try
{
foreach (var data in dataList)
{
var columns = string.Join(", ", data.Keys.Select(k => $"[{k}]"));
var parameters = string.Join(", ", data.Keys.Select(_ => "?"));
var query = $"INSERT INTO {tableName} ({columns}) VALUES ({parameters})";
using var command = new OleDbCommand(query, connection, transaction);
foreach (var value in data.Values)
command.Parameters.Add(new OleDbParameter { Value = value ?? DBNull.Value });
totalInserted += await Task.Run(() => command.ExecuteNonQuery());
}
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
return totalInserted;
}
public async Task<bool> UpsertRecordAsync(string tableName, string keyField, object? keyValue, Dictionary<string, object?> record)
{
try
{
using var connection = new OleDbConnection(_connectionString);
await Task.Run(() => connection.Open());
using var checkCmd = new OleDbCommand($"SELECT COUNT(*) FROM {tableName} WHERE [{keyField}] = ?", connection);
checkCmd.Parameters.Add(new OleDbParameter { Value = keyValue ?? DBNull.Value });
var countResult = await Task.Run(() => checkCmd.ExecuteScalar());
bool exists = Convert.ToInt64(countResult ?? 0L) > 0;
if (exists)
{
var fields = record.Keys.ToList();
var setClauses = fields.Select(f => $"[{f}] = ?").ToList();
var updateSql = $"UPDATE {tableName} SET {string.Join(", ", setClauses)} WHERE [{keyField}] = ?";
using var updateCmd = new OleDbCommand(updateSql, connection);
foreach (var f in fields)
updateCmd.Parameters.Add(new OleDbParameter { Value = record[f] ?? DBNull.Value });
updateCmd.Parameters.Add(new OleDbParameter { Value = keyValue ?? DBNull.Value });
await Task.Run(() => updateCmd.ExecuteNonQuery());
}
else
{
var fields = record.Keys.ToList();
var fieldNames = string.Join(", ", fields.Select(f => $"[{f}]"));
var paramPlaceholders = string.Join(", ", fields.Select(_ => "?"));
var insertSql = $"INSERT INTO {tableName} ({fieldNames}) VALUES ({paramPlaceholders})";
using var insertCmd = new OleDbCommand(insertSql, connection);
foreach (var f in fields)
insertCmd.Parameters.Add(new OleDbParameter { Value = record[f] ?? DBNull.Value });
await Task.Run(() => insertCmd.ExecuteNonQuery());
}
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Errore nell'upsert OLE DB in {tableName}: {ex.Message}");
return false;
}
}
/// <summary>
/// VFP e la maggior parte dei provider OLE DB supportano SELECT TOP N (SQL Server style)
/// </summary>
private static string WrapQueryWithLimit(string query, int maxRows)
{
var upperQuery = query.Trim().ToUpperInvariant();
if (upperQuery.Contains("LIMIT ") || upperQuery.Contains("TOP "))
return query;
if (upperQuery.StartsWith("SELECT "))
return query.Insert(7, $"TOP {maxRows} ");
return $"{query} LIMIT {maxRows}";
}
public void Dispose()
{
// Nessuna risorsa da rilasciare
}
}
+1
View File
@@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.3" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.3" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" /> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" />
<PackageReference Include="System.Data.Odbc" Version="9.0.3" /> <PackageReference Include="System.Data.Odbc" Version="9.0.3" />
<PackageReference Include="System.Data.OleDb" Version="9.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -126,13 +126,19 @@ namespace DataConnection.REST.Implementations
{ {
_accessToken = tokenResponse.AccessToken; _accessToken = tokenResponse.AccessToken;
_instanceUrl = tokenResponse.InstanceUrl; _instanceUrl = tokenResponse.InstanceUrl;
_tokenExpiry = DateTime.UtcNow.AddSeconds(3600); // Salesforce doesn't always return expires_in
// Se Salesforce restituisce expires_in (es. client_credentials), usalo con un margine di 60s;
// altrimenti (password flow non restituisce expires_in) fallback al default 1h conservativo.
var ttlSeconds = tokenResponse.ExpiresIn.HasValue && tokenResponse.ExpiresIn.Value > 60
? tokenResponse.ExpiresIn.Value - 60
: 3600;
_tokenExpiry = DateTime.UtcNow.AddSeconds(ttlSeconds);
_httpClient.DefaultRequestHeaders.Authorization = _httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
_logger.LogInformation("Salesforce authentication ({Flow}) successful. InstanceUrl={InstanceUrl}, TokenExpiry={Expiry}", _logger.LogInformation("Salesforce authentication ({Flow}) successful. InstanceUrl={InstanceUrl}, TokenExpiry={Expiry} (TTL {Ttl}s, server expires_in={ExpiresIn})",
flowName, _instanceUrl, _tokenExpiry.ToLocalTime()); flowName, _instanceUrl, _tokenExpiry.ToLocalTime(), ttlSeconds, tokenResponse.ExpiresIn?.ToString() ?? "null");
return true; return true;
} }
@@ -1616,6 +1622,13 @@ namespace DataConnection.REST.Implementations
[JsonPropertyName("signature")] [JsonPropertyName("signature")]
public string Signature { get; set; } = string.Empty; public string Signature { get; set; } = string.Empty;
/// <summary>
/// Durata di validità del token in secondi. Presente solo in alcuni flussi OAuth Salesforce
/// (es. client_credentials, JWT bearer); assente in altri (es. username/password) — in tal caso 0/null.
/// </summary>
[JsonPropertyName("expires_in")]
public int? ExpiresIn { get; set; }
} }
private class SalesforceSObjectsResponse private class SalesforceSObjectsResponse
@@ -2161,5 +2174,157 @@ namespace DataConnection.REST.Implementations
}).ToList(); }).ToList();
} }
} }
/// <summary>
/// Elimina N record SObject in batch tramite Salesforce Composite API.
/// Riduce N HTTP calls a ceil(N/25), eseguite in parallelo.
/// </summary>
/// <param name="entityName">Nome SObject (es. "Account").</param>
/// <param name="entityIds">Lista di Id da eliminare.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Risultato per ogni Id (Success/ErrorMessage).</returns>
public async Task<List<CompositeOperationResult>> BatchDeleteEntitiesAsync(
string entityName,
List<string> entityIds,
CancellationToken cancellationToken = default)
{
if (!IsAuthenticated())
{
_logger.LogDebug("Error: Not authenticated to Salesforce. Cannot perform batch delete.");
return new List<CompositeOperationResult>();
}
if (entityIds == null || entityIds.Count == 0)
return new List<CompositeOperationResult>();
const int maxBatchSize = 25;
var batches = new List<(List<string> batch, int startIndex, int batchNumber)>();
for (int i = 0; i < entityIds.Count; i += maxBatchSize)
{
var chunk = entityIds.Skip(i).Take(maxBatchSize).ToList();
batches.Add((chunk, i, (i / maxBatchSize) + 1));
}
_logger.LogDebug($"--- BatchDelete: {entityIds.Count} record in {batches.Count} batch (parallel) ---");
var batchTasks = batches.Select(async b =>
await ExecuteDeleteBatchAsync(entityName, b.batch, b.startIndex, cancellationToken));
var batchResults = await Task.WhenAll(batchTasks);
var allResults = new List<CompositeOperationResult>();
foreach (var r in batchResults) allResults.AddRange(r);
_logger.LogDebug($"All delete batches completed: {allResults.Count(r => r.Success)} success, {allResults.Count(r => !r.Success)} failed");
return allResults;
}
private async Task<List<CompositeOperationResult>> ExecuteDeleteBatchAsync(
string entityName,
List<string> batch,
int startIndex,
CancellationToken cancellationToken)
{
try
{
var compositeUri = $"{_instanceUrl}/services/data/v60.0/composite/";
var compositeRequest = new SalesforceCompositeRequest();
for (int i = 0; i < batch.Count; i++)
{
var entityId = batch[i];
compositeRequest.CompositeRequest.Add(new SalesforceCompositeSubRequest
{
Method = "DELETE",
Url = $"/services/data/v60.0/sobjects/{entityName}/{entityId}",
ReferenceId = $"delete_{startIndex + i}"
// Body intenzionalmente null per DELETE
});
}
var jsonContent = new StringContent(
JsonSerializer.Serialize(compositeRequest, SalesforceJsonOptions),
System.Text.Encoding.UTF8,
"application/json");
var response = await _httpClient.PostAsync(compositeUri, jsonContent, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogDebug($"Salesforce Batch Delete failed: {response.StatusCode} - {errorContent}");
return batch.Select((id, idx) => new CompositeOperationResult
{
ReferenceId = $"delete_{startIndex + idx}",
EntityId = id,
Success = false,
ErrorMessage = $"Batch operation failed: {response.StatusCode} - {errorContent}"
}).ToList();
}
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
var compositeResponse = JsonSerializer.Deserialize<SalesforceCompositeResponse>(responseContent, SalesforceJsonOptions);
var results = new List<CompositeOperationResult>();
if (compositeResponse?.CompositeResponse != null)
{
for (int i = 0; i < compositeResponse.CompositeResponse.Count; i++)
{
var sub = compositeResponse.CompositeResponse[i];
var originalId = i < batch.Count ? batch[i] : string.Empty;
var result = new CompositeOperationResult
{
ReferenceId = sub.ReferenceId,
EntityId = originalId,
HttpStatusCode = sub.HttpStatusCode,
// 204 No Content è la risposta di successo standard per DELETE
Success = sub.HttpStatusCode >= 200 && sub.HttpStatusCode < 300
};
if (!result.Success)
result.ErrorMessage = sub.Body?.ToString() ?? "Unknown error";
results.Add(result);
}
}
return results;
}
catch (Exception ex)
{
_logger.LogDebug($"Error during Salesforce batch delete: {ex.Message}");
return batch.Select((id, idx) => new CompositeOperationResult
{
ReferenceId = $"delete_{startIndex + idx}",
EntityId = id,
Success = false,
ErrorMessage = ex.Message
}).ToList();
}
}
/// <summary>
/// Bulk PATCH per aggiornare un singolo campo (es. campo di tombstone/mark-as-deleted) su N record.
/// </summary>
public Task<List<CompositeOperationResult>> BatchPatchSingleFieldAsync(
string entityName,
IEnumerable<string> entityIds,
string fieldName,
object fieldValue,
CancellationToken cancellationToken = default)
{
var updates = entityIds
.Where(id => !string.IsNullOrEmpty(id))
.ToDictionary(
id => id,
_ => (Dictionary<string, object>)new Dictionary<string, object> { { fieldName, fieldValue } });
return BatchUpdateEntitiesAsync(entityName, updates, cancellationToken);
}
} }
} }
-12
View File
@@ -1,12 +0,0 @@
namespace Data_Coupler.Data;
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
@@ -1,19 +0,0 @@
namespace Data_Coupler.Data;
public class WeatherForecastService
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
public Task<WeatherForecast[]> GetForecastAsync(DateOnly startDate)
{
return Task.FromResult(Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
}).ToArray());
}
}
@@ -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;
}
@@ -70,7 +70,6 @@ public partial class DataCoupler : ComponentBase
/// <summary> /// <summary>
/// Verifica se la credenziale database selezionata è di tipo ODBC /// Verifica se la credenziale database selezionata è di tipo ODBC
/// </summary> /// </summary>
/// <returns>True se la credenziale è ODBC, altrimenti False</returns>
protected bool IsOdbcConnection() protected bool IsOdbcConnection()
{ {
if (string.IsNullOrEmpty(selectedDatabaseCredential)) if (string.IsNullOrEmpty(selectedDatabaseCredential))
@@ -80,6 +79,18 @@ public partial class DataCoupler : ComponentBase
return credential?.DatabaseType == DatabaseType.Odbc; return credential?.DatabaseType == DatabaseType.Odbc;
} }
/// <summary>
/// Verifica se la credenziale database selezionata è di tipo OLE DB
/// </summary>
protected bool IsOleDbConnection()
{
if (string.IsNullOrEmpty(selectedDatabaseCredential))
return false;
var credential = databaseCredentials.FirstOrDefault(c => c.Name == selectedDatabaseCredential);
return credential?.DatabaseType == DatabaseType.OleDb;
}
/// <summary> /// <summary>
/// Gestisce il cambio di credenziale database selezionata /// Gestisce il cambio di credenziale database selezionata
/// </summary> /// </summary>
@@ -621,11 +632,12 @@ public partial class DataCoupler : ComponentBase
return; return;
} }
// Per ODBC, crea un database manager temporaneo se non esiste // Per ODBC e OLE DB, crea un database manager temporaneo se non esiste
var managerToUse = currentDatabaseManager; var managerToUse = currentDatabaseManager;
if (managerToUse == null && IsOdbcConnection()) if (managerToUse == null && (IsOdbcConnection() || IsOleDbConnection()))
{ {
Logger.LogInformation("Creando database manager temporaneo per validazione query ODBC"); Logger.LogInformation("Creando database manager temporaneo per validazione query {Type}",
IsOdbcConnection() ? "ODBC" : "OLE DB");
tempManager = await ConnectionFactory.CreateDatabaseManagerAsync(selectedDatabaseCredential); tempManager = await ConnectionFactory.CreateDatabaseManagerAsync(selectedDatabaseCredential);
managerToUse = tempManager; managerToUse = tempManager;
} }
@@ -661,8 +673,8 @@ public partial class DataCoupler : ComponentBase
Logger.LogInformation("Query validata con successo: {ColumnCount} colonne", queryColumns.Count); Logger.LogInformation("Query validata con successo: {ColumnCount} colonne", queryColumns.Count);
// Per ODBC, salva il manager se non era già presente // Per ODBC e OLE DB, salva il manager temporaneo per riuso
if (IsOdbcConnection() && currentDatabaseManager == null && tempManager != null) if ((IsOdbcConnection() || IsOleDbConnection()) && currentDatabaseManager == null && tempManager != null)
{ {
currentDatabaseManager = tempManager; currentDatabaseManager = tempManager;
tempManager = null; // Non distruggerlo nel finally tempManager = null; // Non distruggerlo nel finally
@@ -747,6 +759,7 @@ public partial class DataCoupler : ComponentBase
return databaseType switch return databaseType switch
{ {
DatabaseType.SqlServer => $"SELECT TOP {limit} * FROM ({baseQuery}) AS subquery", DatabaseType.SqlServer => $"SELECT TOP {limit} * FROM ({baseQuery}) AS subquery",
DatabaseType.OleDb => $"SELECT TOP {limit} * FROM ({baseQuery}) AS subquery",
DatabaseType.Oracle => $"SELECT * FROM ({baseQuery}) WHERE ROWNUM <= {limit}", DatabaseType.Oracle => $"SELECT * FROM ({baseQuery}) WHERE ROWNUM <= {limit}",
DatabaseType.MySql => $"{baseQuery} LIMIT {limit}", DatabaseType.MySql => $"{baseQuery} LIMIT {limit}",
DatabaseType.PostgreSql => $"{baseQuery} LIMIT {limit}", DatabaseType.PostgreSql => $"{baseQuery} LIMIT {limit}",
@@ -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;
}
}
}
@@ -8,6 +8,7 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@inject IDataConnectionCredentialService CredentialService @inject IDataConnectionCredentialService CredentialService
@inject IOdbcDsnDiscoveryService OdbcDsnDiscoveryService @inject IOdbcDsnDiscoveryService OdbcDsnDiscoveryService
@inject IOleDbProviderDiscoveryService OleDbProviderDiscoveryService
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject NavigationManager Navigation @inject NavigationManager Navigation
@@ -242,6 +243,7 @@ else
<option value="@CredentialManager.Models.DatabaseType.DB2">DB2</option> <option value="@CredentialManager.Models.DatabaseType.DB2">DB2</option>
<option value="@CredentialManager.Models.DatabaseType.SapHana">SAP HANA</option>*@ <option value="@CredentialManager.Models.DatabaseType.SapHana">SAP HANA</option>*@
<option value="@CredentialManager.Models.DatabaseType.Odbc">ODBC</option> <option value="@CredentialManager.Models.DatabaseType.Odbc">ODBC</option>
<option value="@CredentialManager.Models.DatabaseType.OleDb">OLE DB</option>
</InputSelect> </InputSelect>
</div> </div>
</div> </div>
@@ -465,6 +467,148 @@ else
</div> </div>
</div> </div>
} }
else if (currentDatabaseCredential.DatabaseType == CredentialManager.Models.DatabaseType.OleDb)
{
<!-- Configurazione OLE DB -->
<div class="card mb-3">
<div class="card-header bg-warning text-dark">
<h6 class="mb-0"><i class="oi oi-link-intact"></i> Configurazione OLE DB</h6>
</div>
<div class="card-body">
<div class="alert alert-danger py-2">
<i class="oi oi-warning"></i> <strong>Attenzione — Compatibilità 32-bit:</strong>
Driver come <strong>VFPOLEDB.1</strong> (Visual FoxPro) sono <strong>esclusivamente 32-bit</strong>.
Pubblica l'applicazione con <code>dotnet publish --runtime win-x86</code>.
Vedi <strong>PUBLISH_32BIT_64BIT.md</strong> per tutti i dettagli.
</div>
<div class="mb-3">
<label class="form-label">
Provider OLE DB *
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" @onclick="RefreshOleDbProviderList">
<i class="oi oi-reload"></i> Aggiorna Lista
</button>
</label>
@if (availableOleDbProviders.Any())
{
<select class="form-select" @bind="selectedOleDbProvider">
<option value="">-- Seleziona Provider --</option>
@foreach (var prov in availableOleDbProviders)
{
<option value="@prov.ProgId">@prov.ProgId — @prov.Description</option>
}
</select>
@if (!string.IsNullOrEmpty(selectedOleDbProvider))
{
var info = availableOleDbProviders.FirstOrDefault(p => p.ProgId == selectedOleDbProvider);
if (info?.Note != null)
{
<div class="alert alert-warning mt-2 py-2 small">
<i class="oi oi-warning"></i> @info.Note
</div>
}
}
}
else
{
<div class="alert alert-info py-2 small">
<i class="oi oi-info"></i> Nessun provider OLE DB rilevato automaticamente.
Potrebbe essere necessario eseguire in modalità 32-bit. Inserisci manualmente il ProgID:
</div>
}
<InputText class="form-control mt-1" @bind-Value="selectedOleDbProvider"
placeholder="es. VFPOLEDB.1, Microsoft.ACE.OLEDB.12.0, Microsoft.Jet.OLEDB.4.0" />
<small class="form-text text-muted">
Provider comuni: <code>VFPOLEDB.1</code> (VFP), <code>Microsoft.ACE.OLEDB.12.0</code> (Access/Excel), <code>Microsoft.Jet.OLEDB.4.0</code> (Access 97-2003)
</small>
</div>
@if (IsVfpProvider())
{
<!-- Sezione specifica Visual FoxPro -->
<div class="mb-3">
<label class="form-label">Percorso Database VFP * <small class="text-muted">(.dbc o cartella .dbf)</small></label>
<InputText class="form-control font-monospace"
@bind-Value="currentDatabaseCredential.DatabaseName"
placeholder="es. C:\VFP\Database\miodb.dbc oppure C:\VFP\Tabelle\" />
<small class="form-text text-muted">
Per database container (<code>.dbc</code>): percorso completo al file.<br />
Per tabelle free (<code>.dbf</code>): percorso della cartella contenente i file.
</small>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Collating Sequence</label>
<select class="form-select" @bind="oleDbCollatingSequence">
<option value="">-- Default (machine) --</option>
<option value="machine">machine</option>
<option value="general">general</option>
<option value="spanish">spanish</option>
<option value="dutch">dutch</option>
<option value="french">french</option>
<option value="german">german</option>
<option value="italian">italian</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Record Cancellati (DELETED)</label>
<select class="form-select" @bind="oleDbDeleted">
<option value="">-- Default (ON) --</option>
<option value="ON">ON — escludi record cancellati</option>
<option value="OFF">OFF — includi record cancellati</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Username <small class="text-muted">(raramente richiesto per VFP)</small></label>
<InputText class="form-control" @bind-Value="currentDatabaseCredential.Username" placeholder="Opzionale" />
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Password <small class="text-muted">(raramente richiesta per VFP)</small></label>
<InputText type="password" class="form-control" @bind-Value="currentDatabaseCredential.Password" placeholder="Opzionale" />
</div>
</div>
</div>
}
else
{
<div class="mb-3">
<label class="form-label">Data Source <small class="text-muted">(percorso file o server)</small></label>
<InputText class="form-control" @bind-Value="currentDatabaseCredential.DatabaseName"
placeholder="es. C:\Access\miodb.accdb oppure server\database" />
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Username</label>
<InputText class="form-control" @bind-Value="currentDatabaseCredential.Username" placeholder="Opzionale" />
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Password</label>
<InputText type="password" class="form-control" @bind-Value="currentDatabaseCredential.Password" placeholder="Opzionale" />
</div>
</div>
</div>
}
<div class="mb-3">
<label class="form-label">Anteprima Connection String</label>
<textarea class="form-control font-monospace" rows="3" readonly>@GetOleDbConnectionStringPreview()</textarea>
<small class="form-text text-muted">Anteprima della connection string che verrà generata</small>
</div>
</div>
</div>
}
else else
{ {
<!-- Configurazione Standard Database --> <!-- Configurazione Standard Database -->
@@ -885,6 +1029,12 @@ else
private string selectedOdbcDriver = string.Empty; private string selectedOdbcDriver = string.Empty;
private bool loadingOdbcData = false; private bool loadingOdbcData = false;
// OLE DB specific state
private List<OleDbProviderInfo> availableOleDbProviders = new();
private string selectedOleDbProvider = string.Empty;
private string oleDbCollatingSequence = string.Empty;
private string oleDbDeleted = string.Empty;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ await RefreshCredentials(); { await RefreshCredentials();
CheckForProblematicCredentials(); CheckForProblematicCredentials();
@@ -931,6 +1081,11 @@ else
{ {
await LoadOdbcData(); await LoadOdbcData();
} }
// Se è OLE DB, carica i provider
if (currentDatabaseCredential.DatabaseType == DatabaseType.OleDb)
{
LoadOleDbData();
}
} }
private async Task EditDatabaseCredential(DatabaseCredential credential) private async Task EditDatabaseCredential(DatabaseCredential credential)
@@ -963,6 +1118,19 @@ else
selectedOdbcDriver = currentDatabaseCredential.AdditionalParameters["Driver"]; selectedOdbcDriver = currentDatabaseCredential.AdditionalParameters["Driver"];
} }
} }
// Se è OLE DB, carica i provider e ripristina il provider selezionato
if (currentDatabaseCredential.DatabaseType == DatabaseType.OleDb)
{
LoadOleDbData();
if (currentDatabaseCredential.AdditionalParameters?.ContainsKey("Provider") == true)
{
selectedOleDbProvider = currentDatabaseCredential.AdditionalParameters["Provider"];
}
if (currentDatabaseCredential.AdditionalParameters?.ContainsKey("Collating Sequence") == true)
oleDbCollatingSequence = currentDatabaseCredential.AdditionalParameters["Collating Sequence"];
if (currentDatabaseCredential.AdditionalParameters?.ContainsKey("DELETED") == true)
oleDbDeleted = currentDatabaseCredential.AdditionalParameters["DELETED"];
}
showDatabaseModal = true; showDatabaseModal = true;
} }
@@ -971,6 +1139,22 @@ else
{ {
try try
{ {
// Sincronizza i parametri OLE DB negli AdditionalParameters prima del salvataggio
if (currentDatabaseCredential.DatabaseType == DatabaseType.OleDb)
{
currentDatabaseCredential.AdditionalParameters ??= new Dictionary<string, string>();
if (!string.IsNullOrEmpty(selectedOleDbProvider))
currentDatabaseCredential.AdditionalParameters["Provider"] = selectedOleDbProvider;
if (!string.IsNullOrEmpty(oleDbCollatingSequence))
currentDatabaseCredential.AdditionalParameters["Collating Sequence"] = oleDbCollatingSequence;
else
currentDatabaseCredential.AdditionalParameters.Remove("Collating Sequence");
if (!string.IsNullOrEmpty(oleDbDeleted))
currentDatabaseCredential.AdditionalParameters["DELETED"] = oleDbDeleted;
else
currentDatabaseCredential.AdditionalParameters.Remove("DELETED");
}
await CredentialService.SaveDatabaseCredentialAsync(currentDatabaseCredential); await CredentialService.SaveDatabaseCredentialAsync(currentDatabaseCredential);
await JSRuntime.InvokeVoidAsync("alert", "Credenziale database salvata con successo!"); await JSRuntime.InvokeVoidAsync("alert", "Credenziale database salvata con successo!");
CloseDatabaseModal(); CloseDatabaseModal();
@@ -1289,6 +1473,64 @@ else
StateHasChanged(); StateHasChanged();
} }
// OLE DB Methods
private void LoadOleDbData()
{
try
{
availableOleDbProviders = OleDbProviderDiscoveryService.GetInstalledProviders();
}
catch
{
availableOleDbProviders = new List<OleDbProviderInfo>();
}
}
private void RefreshOleDbProviderList()
{
LoadOleDbData();
StateHasChanged();
}
private bool IsVfpProvider()
{
if (string.IsNullOrEmpty(selectedOleDbProvider))
return false;
return selectedOleDbProvider.StartsWith("VFPOLEDB", StringComparison.OrdinalIgnoreCase);
}
private string GetOleDbConnectionStringPreview()
{
if (currentDatabaseCredential.DatabaseType != DatabaseType.OleDb)
return string.Empty;
try
{
// Copia i parametri OLE DB nei AdditionalParameters temporaneamente
currentDatabaseCredential.AdditionalParameters ??= new Dictionary<string, string>();
if (!string.IsNullOrEmpty(selectedOleDbProvider))
currentDatabaseCredential.AdditionalParameters["Provider"] = selectedOleDbProvider;
if (!string.IsNullOrEmpty(oleDbCollatingSequence))
currentDatabaseCredential.AdditionalParameters["Collating Sequence"] = oleDbCollatingSequence;
else
currentDatabaseCredential.AdditionalParameters.Remove("Collating Sequence");
if (!string.IsNullOrEmpty(oleDbDeleted))
currentDatabaseCredential.AdditionalParameters["DELETED"] = oleDbDeleted;
else
currentDatabaseCredential.AdditionalParameters.Remove("DELETED");
return ConnectionStringBuilder.BuildConnectionString(currentDatabaseCredential);
}
catch (Exception ex)
{
return $"Errore nella generazione: {ex.Message}";
}
}
#endregion #endregion
#endregion #endregion
+278 -11
View File
@@ -50,6 +50,7 @@
<option value="">-- Seleziona Tipo --</option> <option value="">-- Seleziona Tipo --</option>
<option value="database">Database</option> <option value="database">Database</option>
<option value="file">File (Excel/CSV)</option> <option value="file">File (Excel/CSV)</option>
<option value="salesforce">Salesforce (REST API)</option>
</select> </select>
</div> </div>
@@ -709,17 +710,133 @@
</div> </div>
</div> } </div> }
} }
<!-- Sezione Salesforce come Fonte -->
@if (selectedSourceType == "salesforce")
{
<!-- Selezione Credenziali Salesforce -->
<div class="mb-3">
<label class="form-label">Credenziali Salesforce:</label>
<select class="form-select" @onchange="OnSalesforceSourceCredentialChanged" value="@selectedSalesforceSourceCredential">
<option value="">-- Seleziona Salesforce --</option>
@foreach (var cred in salesforceSourceCredentials)
{
<option value="@cred.Name">@cred.Name (@cred.BaseUrl)</option>
}
</select>
</div>
@if (!string.IsNullOrEmpty(selectedSalesforceSourceCredential))
{
<div class="mb-3">
<button class="btn btn-success btn-sm" @onclick="ConnectToSalesforceSource" disabled="@isConnectingSalesforceSource">
@if (isConnectingSalesforceSource)
{
<span class="spinner-border spinner-border-sm me-2"></span>
}
<i class="fas fa-plug"></i> Connetti e Scopri SObject
</button>
@if (isSalesforceSourceConnected)
{
<span class="badge bg-success ms-2">Connesso</span>
}
</div>
}
@if (!string.IsNullOrEmpty(salesforceSourceErrorMessage))
{
<div class="alert alert-danger" role="alert">
@salesforceSourceErrorMessage
</div>
}
<!-- Lista SObject Salesforce -->
@if (salesforceSourceEntities.Any())
{
<div class="mb-3">
<h6>SObject Salesforce (@salesforceSourceEntities.Count disponibili):</h6>
<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 SObject..."
@bind="salesforceSourceSearchTerm" @oninput="FilterSalesforceSourceEntities" />
@if (!string.IsNullOrEmpty(salesforceSourceSearchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearSalesforceSourceSearch">
<i class="fas fa-times"></i>
</button>
}
</div>
</div>
<div class="list-group" style="max-height: 300px; overflow-y: auto;">
@foreach (var entity in GetFilteredSalesforceSourceEntities())
{
<a class="list-group-item list-group-item-action @(selectedSalesforceSourceEntity?.Name == entity.Name ? "active" : "")"
@onclick="@(async () => await SelectSalesforceSourceEntity(entity))">
<div class="d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-cloud"></i> @entity.Name
@if (!string.IsNullOrEmpty(entity.Label))
{
<small class="text-muted d-block">@entity.Label</small>
}
</div>
@if (selectedSalesforceSourceEntity?.Name == entity.Name)
{
<span class="badge bg-primary">Selezionato</span>
}
</div>
</a>
}
</div>
@if (!GetFilteredSalesforceSourceEntities().Any())
{
<div class="alert alert-info mt-2">
<i class="fas fa-info-circle"></i> Nessun SObject trovato con il termine "@salesforceSourceSearchTerm"
</div>
}
</div>
}
}
</div> </div>
</div> </div>
</div> </div>
<!-- Lato Destro - REST API --> <!-- Lato Destro - Destinazione (REST API o Database) -->
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header bg-info text-white"> <div class="card-header bg-info text-white">
<h5><i class="fas fa-cloud"></i> REST API Destination</h5> <div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">
@if (selectedDestinationType == "database")
{
<i class="fas fa-database"></i>
<span> Database Destination</span>
}
else
{
<i class="fas fa-cloud"></i>
<span> REST API Destination</span>
}
</h5>
<!-- Toggle Tipo Destinazione -->
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn @(selectedDestinationType == "rest" ? "btn-light" : "btn-outline-light")"
@onclick="@(() => OnDestinationTypeChanged(new ChangeEventArgs { Value = "rest" }))">
<i class="fas fa-cloud"></i> REST API
</button>
<button type="button" class="btn @(selectedDestinationType == "database" ? "btn-light" : "btn-outline-light")"
@onclick="@(() => OnDestinationTypeChanged(new ChangeEventArgs { Value = "database" }))">
<i class="fas fa-database"></i> Database
</button>
</div>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- ===== DESTINAZIONE REST API ===== -->
@if (selectedDestinationType == "rest")
{
<!-- Selezione Credenziali REST --> <!-- Selezione Credenziali REST -->
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Credenziali REST API:</label> <label class="form-label">Credenziali REST API:</label>
@@ -807,19 +924,111 @@
</div> </div>
} </div> } </div>
} }
} @* fine @if (selectedDestinationType == "rest") *@
<!-- ===== DESTINAZIONE DATABASE ===== -->
@if (selectedDestinationType == "database")
{
<!-- Selezione Credenziali Database Destinazione -->
<div class="mb-3">
<label class="form-label">Credenziali Database:</label>
<select class="form-select" @onchange="OnDestinationDatabaseCredentialChanged" value="@selectedDestinationDatabaseCredential">
<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(selectedDestinationDatabaseCredential))
{
<div class="mb-3">
<button class="btn btn-success btn-sm" @onclick="ConnectToDestinationDatabase" disabled="@isConnectingDestinationDatabase">
@if (isConnectingDestinationDatabase)
{
<span class="spinner-border spinner-border-sm me-2"></span>
}
<i class="fas fa-plug"></i> Connetti e Scopri Tabelle
</button>
@if (isDestinationDatabaseConnected)
{
<span class="badge bg-success ms-2">Connesso</span>
}
</div>
}
@if (!string.IsNullOrEmpty(destinationDatabaseErrorMessage))
{
<div class="alert alert-danger" role="alert">
@destinationDatabaseErrorMessage
</div>
}
<!-- Lista Tabelle Database Destinazione -->
@if (destAvailableTableNames.Any())
{
<div class="mb-3">
<h6>Tabelle Database (@destAvailableTableNames.Count disponibili):</h6>
<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="destDatabaseSearchTerm" @oninput="FilterDestinationTables" />
@if (!string.IsNullOrEmpty(destDatabaseSearchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearDestinationTableSearch">
<i class="fas fa-times"></i>
</button>
}
</div>
</div>
<div class="list-group" style="max-height: 300px; overflow-y: auto;">
@foreach (var table in GetFilteredDestinationTables())
{
<a class="list-group-item list-group-item-action @(selectedDestinationTable == table ? "active" : "")"
@onclick="@(async () => await SelectDestinationTable(table))">
<div class="d-flex justify-content-between align-items-center">
<div>
<i class="fas fa-table"></i> @table
@if (destDatabaseTables.ContainsKey(table))
{
<small class="text-muted d-block">@destDatabaseTables[table].Count() colonne</small>
}
</div>
@if (selectedDestinationTable == table)
{
<span class="badge bg-primary">Selezionata</span>
}
</div>
</a>
}
</div>
@if (!GetFilteredDestinationTables().Any())
{
<div class="alert alert-info mt-2">
<i class="fas fa-info-circle"></i> Nessuna tabella trovata con "@destDatabaseSearchTerm"
</div>
}
</div>
}
}
</div> </div>
</div> </div>
</div> </div>
</div> <!-- Sezione Mapping (quando la fonte è selezionata e REST è connesso) --> </div> <!-- Sezione Mapping -->
@{ @{
// Per ODBC: non richiede isDatabaseConnected, basta query validata // Per ODBC: non richiede isDatabaseConnected, basta query validata
// Per altri database: richiede connessione + (query validata OR tabella selezionata) // Per altri database: richiede connessione + (query validata OR tabella selezionata)
var isSourceReady = (selectedSourceType == "database" && var isSourceReady = (selectedSourceType == "database" &&
((IsOdbcConnection() && useCustomQuery && isQueryValid) || ((IsOdbcConnection() && useCustomQuery && isQueryValid) ||
(!IsOdbcConnection() && isDatabaseConnected && ((useCustomQuery && isQueryValid) || (!useCustomQuery && !string.IsNullOrEmpty(selectedTable)))))) || (!IsOdbcConnection() && isDatabaseConnected && ((useCustomQuery && isQueryValid) || (!useCustomQuery && !string.IsNullOrEmpty(selectedTable)))))) ||
(selectedSourceType == "file" && !string.IsNullOrEmpty(selectedSheet)); (selectedSourceType == "file" && !string.IsNullOrEmpty(selectedSheet)) ||
(selectedSourceType == "salesforce" && isSalesforceSourceConnected && selectedSalesforceSourceEntity != null);
var isDestinationReady = (selectedDestinationType == "rest" && isRestConnected && selectedRestEntity != null) ||
(selectedDestinationType == "database" && IsDestinationDatabaseReady);
} }
@if (isSourceReady && isRestConnected && selectedRestEntity != null) @if (isSourceReady && isDestinationReady)
{ {
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12"> <div class="col-12">
@@ -828,11 +1037,17 @@
<h5><i class="fas fa-exchange-alt"></i> Mapping Campi</h5> <h5><i class="fas fa-exchange-alt"></i> Mapping Campi</h5>
</div> <div class="card-body"> </div> <div class="card-body">
@{ @{
var sourceDisplayName = selectedSourceType == "database" ? selectedTable : selectedSheet; var sourceDisplayName = selectedSourceType == "database" ? selectedTable :
var sourceTypeName = selectedSourceType == "database" ? "Tabella" : "Foglio"; selectedSourceType == "salesforce" ? selectedSalesforceSourceEntity?.Name ?? "" :
selectedSheet;
var sourceTypeName = selectedSourceType == "database" ? "Tabella" :
selectedSourceType == "salesforce" ? "SObject" :
"Foglio";
var destDisplayName = selectedDestinationType == "database" ? selectedDestinationTable : selectedRestEntity?.Name ?? "";
var destTypeName = selectedDestinationType == "database" ? "Tabella DB" : "Entità REST";
} }
<h6>Mapping tra @sourceTypeName @sourceDisplayName e @selectedRestEntity.Name</h6> <h6>Mapping tra @sourceTypeName @sourceDisplayName e @destDisplayName</h6>
<p class="text-muted">Configura il mapping tra i campi della fonte dati e le proprietà dell'entità REST</p> <p class="text-muted">Configura il mapping tra i campi della fonte dati e le proprietà della destinazione</p>
<div class="row"> <div class="row">
<!-- Colonna Sinistra: Campi Fonte --> <!-- Colonna Sinistra: Campi Fonte -->
<div class="col-5"> <div class="col-5">
@@ -914,6 +1129,27 @@
</a> </a>
} }
} }
else if (selectedSourceType == "salesforce" && salesforceSourceEntityDetails != null)
{
@foreach (var field in salesforceSourceEntityDetails.Properties)
{
<a class="list-group-item list-group-item-action @(selectedDbColumn == field.Name ? "active" : "")"
@onclick="@(() => SelectDbColumn(field.Name))">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>@field.Name</strong>
<small class="text-muted d-block">@field.Type</small>
</div>
<div>
@if (fieldMappings.ContainsKey(field.Name))
{
<span class="badge bg-success">Mapped</span>
}
</div>
</div>
</a>
}
}
</div> </div>
</div> </div>
@@ -998,9 +1234,39 @@
</div> </div>
</div> </div>
<!-- Colonna Destra: Proprietà REST --> <!-- Colonna Destra: Proprietà Destinazione -->
<div class="col-5"> <div class="col-5">
<h6>Proprietà REST (@selectedRestEntity.Name)</h6> @if (selectedDestinationType == "database" && !string.IsNullOrEmpty(selectedDestinationTable) && destDatabaseTables.ContainsKey(selectedDestinationTable))
{
<h6>Colonne Tabella (@selectedDestinationTable)</h6>
<div class="list-group" style="max-height: 400px; overflow-y: auto;">
@foreach (var col in destDatabaseTables[selectedDestinationTable])
{
<a class="list-group-item list-group-item-action @(selectedRestProperty == col.Name ? "active" : "")"
@onclick="@(() => SelectRestProperty(col.Name))">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>@col.Name</strong>
<small class="text-muted d-block">@col.DataType</small>
</div>
<div>
@if (col.IsPrimaryKey)
{
<span class="badge bg-warning text-dark">PK</span>
}
@if (fieldMappings.ContainsValue(col.Name))
{
<span class="badge bg-success">Mapped</span>
}
</div>
</div>
</a>
}
</div>
}
else
{
<h6>Proprietà REST (@(selectedRestEntity?.Name ?? ""))</h6>
<div class="list-group" style="max-height: 400px; overflow-y: auto;"> <div class="list-group" style="max-height: 400px; overflow-y: auto;">
@if (restEntityDetails != null) @if (restEntityDetails != null)
{ {
@@ -1032,6 +1298,7 @@
} }
} }
</div> </div>
}
</div> </div>
</div> </div>
+132 -54
View File
@@ -97,6 +97,7 @@ public partial class DataCoupler : ComponentBase
{ {
databaseCredentials = await CredentialService.GetAllDatabaseCredentialsAsync(); databaseCredentials = await CredentialService.GetAllDatabaseCredentialsAsync();
await LoadRestCredentials(); // Carica le credenziali REST dalla classe parziale await LoadRestCredentials(); // Carica le credenziali REST dalla classe parziale
await LoadSalesforceSourceCredentials(); // Carica le credenziali Salesforce per la fonte
// Carica anche i profili disponibili // Carica anche i profili disponibili
await LoadProfiles(); await LoadProfiles();
} }
@@ -809,6 +810,7 @@ public partial class DataCoupler : ComponentBase
restSearchTerm = ""; restSearchTerm = "";
currentRestDiscovery = null; currentRestDiscovery = null;
currentRestClient = null; currentRestClient = null;
ResetDestinationDatabaseState();
} }
private void OnSourceTypeChanged(ChangeEventArgs e) private void OnSourceTypeChanged(ChangeEventArgs e)
@@ -833,6 +835,9 @@ public partial class DataCoupler : ComponentBase
fileData.Clear(); fileData.Clear();
selectedSheet = ""; selectedSheet = "";
// Reset Salesforce source state
ResetSalesforceSourceState();
// Reset pagination // Reset pagination
currentPage = 1; currentPage = 1;
@@ -1766,25 +1771,103 @@ public partial class DataCoupler : ComponentBase
} }
private async Task StartDataTransfer() private async Task StartDataTransfer()
{ {
// Verifica se possiamo utilizzare le chiamate Composite (solo per Salesforce) // Se destinazione è database, usa il metodo dedicato
if (selectedDestinationType == "database")
{
await StartDataTransferToDatabase();
return;
}
// Verifica se possiamo utilizzare le chiamate Composite (solo per Salesforce REST dest)
if (currentRestClient is DataConnection.REST.Implementations.SalesforceServiceClient) if (currentRestClient is DataConnection.REST.Implementations.SalesforceServiceClient)
{ {
await StartDataTransferWithComposite(); await StartDataTransferWithComposite();
return; return;
} }
// Fallback al metodo originale per altri client REST // Per altri client REST, usa il metodo originale
// Se siamo con Salesforce, usa il nuovo metodo Composite
if (currentRestClient is DataConnection.REST.Implementations.SalesforceServiceClient)
{
await StartDataTransferWithComposite();
return;
}
// Per altri client, usa il metodo originale
await StartDataTransferOriginal(); await StartDataTransferOriginal();
} }
private async Task StartDataTransferToDatabase()
{
if (!fieldMappings.Any() || currentDestinationDatabaseManager == null || string.IsNullOrEmpty(selectedDestinationTable))
{
transferMessage = "Configurazione incompleta. Assicurati di aver selezionato la fonte, la tabella di destinazione e configurato almeno un mapping.";
transferMessageType = "error";
return;
}
if (string.IsNullOrEmpty(sourceKeyField))
{
transferMessage = "Seleziona un campo chiave sorgente per l'operazione di upsert.";
transferMessageType = "error";
return;
}
isTransferringData = true;
transferMessage = "";
int successCount = 0;
int errorCount = 0;
StateHasChanged();
try
{
var sourceRecords = await GetAllRecordsFromSource();
var recordsList = sourceRecords.ToList();
transferMessage = $"Elaborazione di {recordsList.Count} record...";
StateHasChanged();
foreach (var sourceRecord in recordsList)
{
try
{
// Applica mapping
var destRecord = new Dictionary<string, object?>();
foreach (var mapping in fieldMappings)
{
if (sourceRecord.TryGetValue(mapping.Key, out var value))
destRecord[mapping.Value] = value;
}
// Aggiungi default values
foreach (var dv in defaultValues)
{
if (!destRecord.ContainsKey(dv.Key))
destRecord[dv.Key] = dv.Value;
}
// Determina chiave di destinazione
string destKeyField = fieldMappings.TryGetValue(sourceKeyField, out var mapped) ? mapped : sourceKeyField;
object? keyValue = sourceRecord.TryGetValue(sourceKeyField, out var kv) ? kv : null;
var ok = await currentDestinationDatabaseManager.UpsertRecordAsync(
selectedDestinationTable, destKeyField, keyValue, destRecord);
if (ok) successCount++;
else errorCount++;
}
catch
{
errorCount++;
}
}
transferMessage = $"Trasferimento completato: {successCount} record elaborati, {errorCount} errori.";
transferMessageType = errorCount == 0 ? "success" : "warning";
}
catch (Exception ex)
{
transferMessage = $"Errore durante il trasferimento: {ex.Message}";
transferMessageType = "error";
Logger.LogError(ex, "Errore nel trasferimento verso database");
}
finally
{
isTransferringData = false;
StateHasChanged();
}
}
private async Task StartDataTransferOriginal() private async Task StartDataTransferOriginal()
{ {
if (!fieldMappings.Any() || currentRestClient == null || selectedRestEntity == null) if (!fieldMappings.Any() || currentRestClient == null || selectedRestEntity == null)
@@ -2248,6 +2331,10 @@ public partial class DataCoupler : ComponentBase
{ {
return await GetAllRecordsFromFile(); return await GetAllRecordsFromFile();
} }
else if (selectedSourceType == "salesforce")
{
return await GetAllRecordsFromSalesforceSource();
}
return new List<Dictionary<string, object>>(); return new List<Dictionary<string, object>>();
} }
@@ -3404,10 +3491,42 @@ public partial class DataCoupler : ComponentBase
// Crea lista indicizzata per mantenere il record number // Crea lista indicizzata per mantenere il record number
var indexedRecords = records.Select((record, index) => new { Record = record, RecordNumber = index + 1 }).ToList(); var indexedRecords = records.Select((record, index) => new { Record = record, RecordNumber = index + 1 }).ToList();
Logger.LogInformation("COMPOSITE: Inizio analisi parallela di {RecordCount} record", indexedRecords.Count); Logger.LogInformation("COMPOSITE: Inizio analisi di {RecordCount} record", indexedRecords.Count);
var analysisStartTime = DateTime.UtcNow; var analysisStartTime = DateTime.UtcNow;
// Processa tutti i record in parallelo // === STEP A: Bulk Pre-Discovery (1 query SQLite + poche SOQL IN invece di 2N+N) ===
var sourceKeysForBulk = new List<string>(indexedRecords.Count);
foreach (var idx in indexedRecords)
{
var key = GenerateSourceKey(idx.Record);
if (!string.IsNullOrEmpty(key))
sourceKeysForBulk.Add(key);
}
Dictionary<string, KeyAssociation> associationsByKey = new(StringComparer.Ordinal);
if (currentUseRecordAssociations && !string.IsNullOrEmpty(currentSourceKeyField) && sourceKeysForBulk.Count > 0)
{
var commonRequest = new PreDiscoveryRequest
{
SourceKeyField = currentSourceKeyField,
DestinationEntity = currentEntityName,
CredentialName = currentCredentialName,
DestinationKeyField = GetEntityIdField(),
FieldMappings = currentFieldMappings,
RestClient = currentRestClient,
EnablePreDiscovery = true,
UseParallelMethod = true,
IsScheduledTransfer = false
};
associationsByKey = await AssociationService.BatchFindOrCreateAssociationsAsync(
sourceKeysForBulk, commonRequest);
Logger.LogInformation("COMPOSITE: Bulk Pre-Discovery completata - {Found}/{Total} associazioni risolte",
associationsByKey.Count, sourceKeysForBulk.Count);
}
// === STEP B: Analisi locale parallela (no I/O) ===
var processingTasks = indexedRecords.Select(async indexedRecord => var processingTasks = indexedRecords.Select(async indexedRecord =>
{ {
try try
@@ -3426,48 +3545,7 @@ public partial class DataCoupler : ComponentBase
// Analizza le associazioni per capire se aggiornare, creare o saltare // Analizza le associazioni per capire se aggiornare, creare o saltare
if (currentUseRecordAssociations && !string.IsNullOrEmpty(sourceKey)) if (currentUseRecordAssociations && !string.IsNullOrEmpty(sourceKey))
{ {
Logger.LogDebug("COMPOSITE PARALLEL: Cerco associazione per KeyValue: '{KeyValue}', Entity: '{Entity}', Credential: '{Credential}'", associationsByKey.TryGetValue(sourceKey, out var existingAssociation);
sourceKey, currentEntityName, currentCredentialName);
// Usa i metodi paralleli per le operazioni di database
var existingAssociation = await CredentialService.FindKeyAssociationByValueParallelAsync(
sourceKey, currentEntityName, currentCredentialName);
// FALLBACK: Se non troviamo l'associazione con tutti i parametri, proviamo solo con il KeyValue
if (existingAssociation == null)
{
existingAssociation = await CredentialService.FindKeyAssociationByValueParallelAsync(sourceKey);
if (existingAssociation != null)
{
// Verifica compatibilità
if (existingAssociation.DestinationEntity != currentEntityName ||
existingAssociation.RestCredentialName != currentCredentialName)
{
existingAssociation = null;
}
}
}
// 🔍 PRE-DISCOVERY: Usa il servizio centralizzato
if (existingAssociation == null)
{
var preDiscoveryRequest = new PreDiscoveryRequest
{
SourceKey = sourceKey,
SourceKeyField = currentSourceKeyField,
DestinationEntity = currentEntityName,
CredentialName = currentCredentialName,
DestinationKeyField = GetEntityIdField(),
FieldMappings = currentFieldMappings,
RestClient = currentRestClient,
CurrentDataHash = currentDataHash,
EnablePreDiscovery = true,
UseParallelMethod = true, // Usa metodi paralleli thread-safe
IsScheduledTransfer = false
};
existingAssociation = await AssociationService.FindOrCreateAssociationAsync(preDiscoveryRequest);
}
if (existingAssociation != null && existingAssociation.IsActive) if (existingAssociation != null && existingAssociation.IsActive)
{ {
-47
View File
@@ -1,47 +0,0 @@
@page "/fetchdata"
@using Data_Coupler.Data
@inject WeatherForecastService ForecastService
<PageTitle>Weather forecast</PageTitle>
<h1>Weather forecast</h1>
<p>This component demonstrates fetching data from a service.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
forecasts = await ForecastService.GetForecastAsync(DateOnly.FromDateTime(DateTime.Now));
}
}
+1
View File
@@ -109,6 +109,7 @@ builder.Services.AddScoped<IDataConnectionFactory, DataConnectionFactory>();
// Register ODBC DSN Discovery Service // Register ODBC DSN Discovery Service
builder.Services.AddScoped<CredentialManager.Services.IOdbcDsnDiscoveryService, CredentialManager.Services.OdbcDsnDiscoveryService>(); builder.Services.AddScoped<CredentialManager.Services.IOdbcDsnDiscoveryService, CredentialManager.Services.OdbcDsnDiscoveryService>();
builder.Services.AddScoped<CredentialManager.Services.IOleDbProviderDiscoveryService, CredentialManager.Services.OleDbProviderDiscoveryService>();
// Register Association Service (Pre-Discovery) // Register Association Service (Pre-Discovery)
builder.Services.AddScoped<Data_Coupler.Services.IAssociationService, Data_Coupler.Services.AssociationService>(); builder.Services.AddScoped<Data_Coupler.Services.IAssociationService, Data_Coupler.Services.AssociationService>();
+1 -1
View File
@@ -12,7 +12,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"applicationUrl": "http://localhost:5135", "applicationUrl": "http://localhost:7550",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
+239
View File
@@ -1,11 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using CredentialManager.Models; using CredentialManager.Models;
using CredentialManager.Services; using CredentialManager.Services;
using DataConnection.CredentialManagement.Interfaces; using DataConnection.CredentialManagement.Interfaces;
using DataConnection.REST.Implementations;
using DataConnection.REST.Interfaces; using DataConnection.REST.Interfaces;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -69,6 +71,227 @@ public class AssociationService : IAssociationService
return null; return null;
} }
/// <summary>
/// Versione bulk del find-or-create — vedi <see cref="IAssociationService.BatchFindOrCreateAssociationsAsync"/>.
/// </summary>
public async Task<Dictionary<string, KeyAssociation>> BatchFindOrCreateAssociationsAsync(
IEnumerable<string> sourceKeys,
PreDiscoveryRequest commonRequest)
{
if (commonRequest == null)
throw new ArgumentNullException(nameof(commonRequest));
var distinctKeys = sourceKeys
.Where(k => !string.IsNullOrEmpty(k))
.Distinct()
.ToList();
var result = new Dictionary<string, KeyAssociation>(StringComparer.Ordinal);
if (distinctKeys.Count == 0)
return result;
// STEP 1 — Bulk lookup nel DB locale (1 query SQLite invece di N)
Dictionary<string, KeyAssociation> localMatches;
try
{
localMatches = await _credentialService.FindKeyAssociationsByValuesBulkAsync(
distinctKeys, commonRequest.DestinationEntity, commonRequest.CredentialName);
}
catch (Exception ex)
{
_logger.LogError(ex, "BULK PRE-DISCOVERY: bulk lookup locale fallito, fallback per-record");
// Fallback per-record (mantiene comportamento esistente in caso di problemi)
foreach (var key in distinctKeys)
{
var req = ClonePreDiscoveryRequest(commonRequest, key);
var found = await FindOrCreateAssociationAsync(req);
if (found != null) result[key] = found;
}
return result;
}
foreach (var kvp in localMatches)
result[kvp.Key] = kvp.Value;
var missingKeys = distinctKeys.Where(k => !result.ContainsKey(k)).ToList();
_logger.LogInformation("BULK PRE-DISCOVERY: {Local}/{Total} associazioni trovate localmente, {Missing} da cercare nella destinazione",
localMatches.Count, distinctKeys.Count, missingKeys.Count);
if (missingKeys.Count == 0 || !commonRequest.EnablePreDiscovery)
return result;
// STEP 2 — Pre-Discovery batched sulla destinazione REST
// Verifica che il campo chiave sia mappato
if (string.IsNullOrEmpty(commonRequest.SourceKeyField) ||
!commonRequest.FieldMappings.TryGetValue(commonRequest.SourceKeyField, out var mappedField))
{
_logger.LogWarning("BULK PRE-DISCOVERY: campo chiave '{SourceKeyField}' non mappato; skip discovery",
commonRequest.SourceKeyField);
return result;
}
// Solo SalesforceServiceClient supporta SOQL IN ottimizzata; per altri client si ricade al per-record.
if (commonRequest.RestClient is SalesforceServiceClient sfClient)
{
var discovered = await PerformBulkPreDiscoverySalesforceAsync(
sfClient, missingKeys, mappedField, commonRequest);
foreach (var kvp in discovered)
result[kvp.Key] = kvp.Value;
}
else
{
_logger.LogDebug("BULK PRE-DISCOVERY: client REST non Salesforce, fallback per-record per {Count} chiavi", missingKeys.Count);
foreach (var key in missingKeys)
{
var req = ClonePreDiscoveryRequest(commonRequest, key);
var found = await PerformPreDiscoveryAsync(req);
if (found != null) result[key] = found;
}
}
return result;
}
/// <summary>
/// Pre-Discovery batched specifica per Salesforce: usa SOQL <c>WHERE field IN (...)</c>
/// per recuperare in pochissime chiamate API tutti i record che matchano una qualsiasi delle chiavi mancanti.
/// </summary>
private async Task<Dictionary<string, KeyAssociation>> PerformBulkPreDiscoverySalesforceAsync(
SalesforceServiceClient sfClient,
List<string> missingKeys,
string mappedDestinationField,
PreDiscoveryRequest commonRequest)
{
var output = new Dictionary<string, KeyAssociation>(StringComparer.Ordinal);
// Chunk per stare sotto il limite SOQL/URL (~16 KB GET): ~200 valori per query
const int chunkSize = 200;
var queries = new List<string>();
for (int i = 0; i < missingKeys.Count; i += chunkSize)
{
var chunk = missingKeys.Skip(i).Take(chunkSize).ToList();
var sb = new StringBuilder();
sb.Append("SELECT Id, ");
sb.Append(mappedDestinationField);
sb.Append(" FROM ");
sb.Append(commonRequest.DestinationEntity);
sb.Append(" WHERE ");
sb.Append(mappedDestinationField);
sb.Append(" IN (");
sb.Append(string.Join(",", chunk.Select(v => $"'{v.Replace("'", "\\'")}'")));
sb.Append(')');
queries.Add(sb.ToString());
}
_logger.LogInformation("BULK PRE-DISCOVERY: {QueryCount} SOQL IN-query (~{ChunkSize} chiavi/query, Composite API ceil(N/25) HTTP call)",
queries.Count, chunkSize);
// BatchExecuteQueriesAsync raggruppa fino a 25 query in 1 Composite request
var batchResults = await sfClient.BatchExecuteQueriesAsync(queries);
// Indicizza i risultati per chiave: dal record letto leggiamo il valore di mappedDestinationField
var entityIdByKey = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var batchResult in batchResults.Where(r => r.Success && r.Records != null))
{
foreach (var record in batchResult.Records)
{
if (!record.TryGetValue(mappedDestinationField, out var keyVal) || keyVal == null)
continue;
var keyStr = keyVal.ToString();
if (string.IsNullOrEmpty(keyStr))
continue;
var idStr = ExtractDestinationId(record);
if (string.IsNullOrEmpty(idStr))
continue;
// In caso di duplicati in Salesforce, prendiamo il primo
if (!entityIdByKey.ContainsKey(keyStr))
entityIdByKey[keyStr] = idStr;
}
}
_logger.LogInformation("BULK PRE-DISCOVERY: trovati {Found}/{Missing} record esistenti nella destinazione",
entityIdByKey.Count, missingKeys.Count);
if (entityIdByKey.Count == 0)
return output;
// Salvataggio associazioni Pre-Discovery in parallelo
var saveTasks = entityIdByKey.Select(async kvp =>
{
try
{
var newAssoc = new KeyAssociation
{
KeyValue = kvp.Key,
SourceKeyField = commonRequest.SourceKeyField,
DestinationKeyField = commonRequest.DestinationKeyField ?? "Id",
MappedDestinationField = mappedDestinationField,
DestinationEntity = commonRequest.DestinationEntity,
DestinationId = kvp.Value,
RestCredentialName = commonRequest.CredentialName,
CreatedAt = DateTime.UtcNow,
LastVerifiedAt = DateTime.UtcNow,
IsActive = true,
AdditionalInfo = JsonSerializer.Serialize(new Dictionary<string, object>
{
{ "CreatedBy", "PreDiscovery" },
{ "DiscoveredAt", DateTime.UtcNow },
{ "MappingCount", commonRequest.FieldMappings.Count },
{ "BulkPreDiscovery", true },
{ "ScheduledTransfer", commonRequest.IsScheduledTransfer },
{ "SourceType", commonRequest.SourceType ?? string.Empty }
})
};
var id = commonRequest.UseParallelMethod
? await _credentialService.SaveKeyAssociationParallelAsync(newAssoc)
: await _credentialService.SaveKeyAssociationAsync(newAssoc);
newAssoc.Id = id;
return (kvp.Key, newAssoc);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "BULK PRE-DISCOVERY: errore nel salvataggio associazione per KeyValue '{KeyValue}'", kvp.Key);
return (kvp.Key, (KeyAssociation?)null);
}
});
var savedResults = await Task.WhenAll(saveTasks);
foreach (var (key, assoc) in savedResults)
{
if (assoc != null) output[key] = assoc;
}
return output;
}
private static PreDiscoveryRequest ClonePreDiscoveryRequest(PreDiscoveryRequest source, string sourceKey)
{
return new PreDiscoveryRequest
{
SourceKey = sourceKey,
SourceKeyField = source.SourceKeyField,
DestinationEntity = source.DestinationEntity,
CredentialName = source.CredentialName,
DestinationKeyField = source.DestinationKeyField,
FieldMappings = source.FieldMappings,
RestClient = source.RestClient,
CurrentDataHash = source.CurrentDataHash,
EnablePreDiscovery = source.EnablePreDiscovery,
UseParallelMethod = source.UseParallelMethod,
IsScheduledTransfer = source.IsScheduledTransfer,
SourceType = source.SourceType
};
}
/// <summary> /// <summary>
/// Verifica se un'associazione è stata creata dal Pre-Discovery /// Verifica se un'associazione è stata creata dal Pre-Discovery
/// controllando il campo AdditionalInfo /// controllando il campo AdditionalInfo
@@ -285,6 +508,22 @@ public interface IAssociationService
{ {
Task<KeyAssociation?> FindOrCreateAssociationAsync(PreDiscoveryRequest request); Task<KeyAssociation?> FindOrCreateAssociationAsync(PreDiscoveryRequest request);
bool IsPreDiscoveryAssociation(KeyAssociation association); bool IsPreDiscoveryAssociation(KeyAssociation association);
/// <summary>
/// Versione bulk del find-or-create.
/// 1) Una sola query SQLite (WHERE KeyValue IN …) per recuperare le associazioni esistenti.
/// 2) Per le chiavi non trovate localmente, una manciata di SOQL "IN" su Salesforce
/// (~200 chiavi per query, Composite API: ceil(K/25) HTTP call) invece di K chiamate singole.
/// 3) Le associazioni Pre-Discovery scoperte vengono salvate e restituite.
/// </summary>
/// <param name="sourceKeys">Lista (non vuota) dei valori chiave sorgente per tutti i record da analizzare.</param>
/// <param name="commonRequest">Parametri condivisi (entity, credential, restClient, mappings, ecc.).
/// <see cref="PreDiscoveryRequest.SourceKey"/> e <see cref="PreDiscoveryRequest.CurrentDataHash"/>
/// sono ignorati; vengono presi dal parametro <paramref name="sourceKeys"/>.</param>
/// <returns>Dizionario KeyValue → KeyAssociation (solo per chiavi trovate/create).</returns>
Task<Dictionary<string, KeyAssociation>> BatchFindOrCreateAssociationsAsync(
IEnumerable<string> sourceKeys,
PreDiscoveryRequest commonRequest);
} }
/// <summary> /// <summary>
@@ -83,6 +83,14 @@ namespace Data_Coupler.Services
return new DataConnection.DB.OdbcDatabaseManager(connectionString); return new DataConnection.DB.OdbcDatabaseManager(connectionString);
} }
// Per OLE DB, usa OleDbDatabaseManager direttamente (EF Core non supporta OLE DB)
if (credential.DatabaseType == DatabaseType.OleDb)
{
var connectionString = CredentialManager.Models.ConnectionStringBuilder.BuildConnectionString(credential);
_logger.LogInformation("Creando OleDbDatabaseManager con connection string per {CredentialName}", credentialName);
return new DataConnection.DB.OleDbDatabaseManager(connectionString);
}
// Per altri database, usa EFCoreDatabaseManager // Per altri database, usa EFCoreDatabaseManager
var dbManagerOptions = await _credentialService.GetDbManagerOptionsAsync(credential.Name); var dbManagerOptions = await _credentialService.GetDbManagerOptionsAsync(credential.Name);
return new EFCoreDatabaseManager(dbManagerOptions); return new EFCoreDatabaseManager(dbManagerOptions);
+186 -66
View File
@@ -1,5 +1,6 @@
using CredentialManager.Models; using CredentialManager.Models;
using DataConnection.CredentialManagement.Interfaces; using DataConnection.CredentialManagement.Interfaces;
using DataConnection.REST.Implementations;
using DataConnection.REST.Interfaces; using DataConnection.REST.Interfaces;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -79,76 +80,18 @@ public class DeletionSyncService : IDeletionSyncService
_logger.LogInformation("Trovate {Count} cancellazioni in attesa di sincronizzazione", _logger.LogInformation("Trovate {Count} cancellazioni in attesa di sincronizzazione",
pendingDeletions.Count); pendingDeletions.Count);
// Step 3: Esegui le cancellazioni nella destinazione // Step 3: Esegui le cancellazioni nella destinazione.
foreach (var deletion in pendingDeletions) // Per Salesforce usiamo le Composite API in batch (ceil(N/25) HTTP call invece di N);
// per gli altri client REST manteniamo il loop sequenziale (nessun batch supportato).
if (restClient is SalesforceServiceClient salesforceClient)
{ {
try await ExecuteBatchedSalesforceDeletionsAsync(
{ salesforceClient, destinationEntity, pendingDeletions, options, result);
bool syncSuccess = false;
string errorMessage = "";
switch (options.Action)
{
case DeletionAction.Delete:
// Elimina fisicamente il record
syncSuccess = await DeleteRecordAsync(
restClient, destinationEntity, deletion.DestinationId);
break;
case DeletionAction.Deactivate:
// Marca il record come inattivo
syncSuccess = await DeactivateRecordAsync(
restClient, destinationEntity, deletion.DestinationId);
break;
case DeletionAction.Mark:
// Imposta un campo personalizzato
if (string.IsNullOrEmpty(options.MarkField) || string.IsNullOrEmpty(options.MarkValue))
{
errorMessage = "MarkField e MarkValue devono essere specificati per DeletionAction.Mark";
_logger.LogWarning(errorMessage);
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {errorMessage}");
continue;
}
syncSuccess = await MarkRecordAsync(
restClient, destinationEntity, deletion.DestinationId,
options.MarkField, options.MarkValue);
break;
default:
errorMessage = $"Azione di cancellazione non supportata: {options.Action}";
_logger.LogWarning(errorMessage);
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {errorMessage}");
continue;
}
if (syncSuccess)
{
// Marca la cancellazione come sincronizzata
await _credentialService.MarkDeletionSyncedAsync(deletion.Id);
result.DeletedRecordsSynced++;
_logger.LogInformation(
"Cancellazione sincronizzata: KeyValue={KeyValue}, DestinationId={DestinationId}, Action={Action}",
deletion.KeyValue, deletion.DestinationId, options.Action);
} }
else else
{ {
result.SyncErrors++; await ExecuteSequentialDeletionsAsync(
var error = $"Errore nella sincronizzazione della cancellazione per KeyValue: {deletion.KeyValue}"; restClient, destinationEntity, pendingDeletions, options, result);
result.Errors.Add(error);
_logger.LogWarning(error);
}
}
catch (Exception ex)
{
result.SyncErrors++;
var error = $"Errore durante la sincronizzazione della cancellazione per KeyValue: {deletion.KeyValue} - {ex.Message}";
result.Errors.Add(error);
_logger.LogError(ex, "Errore nella sincronizzazione della cancellazione per {KeyValue}",
deletion.KeyValue);
}
} }
result.IsSuccess = result.SyncErrors == 0; result.IsSuccess = result.SyncErrors == 0;
@@ -170,6 +113,183 @@ public class DeletionSyncService : IDeletionSyncService
return result; return result;
} }
/// <summary>
/// Esegue le cancellazioni in batch via Salesforce Composite API.
/// Riduce N round-trip HTTP a ceil(N/25) batch in parallelo.
/// </summary>
private async Task ExecuteBatchedSalesforceDeletionsAsync(
SalesforceServiceClient salesforceClient,
string destinationEntity,
List<KeyAssociation> pendingDeletions,
DeletionSyncOptions options,
DeletionSyncResult result)
{
// Per Mark serve MarkField e MarkValue: validazione preventiva (un solo log)
if (options.Action == DeletionAction.Mark &&
(string.IsNullOrEmpty(options.MarkField) || string.IsNullOrEmpty(options.MarkValue)))
{
const string err = "MarkField e MarkValue devono essere specificati per DeletionAction.Mark";
_logger.LogWarning(err);
foreach (var d in pendingDeletions)
{
result.SyncErrors++;
result.Errors.Add($"KeyValue: {d.KeyValue} - {err}");
}
return;
}
// Mappa entityId → KeyAssociation per ricostruire l'associazione dal risultato batch
var deletionsById = pendingDeletions
.Where(d => !string.IsNullOrEmpty(d.DestinationId))
.GroupBy(d => d.DestinationId)
.ToDictionary(g => g.Key, g => g.First()); // se duplicati, prima occorrenza
var entityIds = deletionsById.Keys.ToList();
if (entityIds.Count == 0)
return;
_logger.LogInformation("DELETION SYNC (Salesforce batched): {Count} record, action={Action}",
entityIds.Count, options.Action);
List<DataConnection.REST.Implementations.SalesforceServiceClient.CompositeOperationResult> batchResults;
try
{
switch (options.Action)
{
case DeletionAction.Delete:
batchResults = await salesforceClient.BatchDeleteEntitiesAsync(destinationEntity, entityIds);
break;
case DeletionAction.Deactivate:
// Aggiorna IsActive/Active = false in batch.
// Non sappiamo a priori quale dei due campi esista sull'SObject: proviamo IsActive,
// se Salesforce ritorna errore il record verrà segnalato come fallito.
var deactivateUpdates = entityIds.ToDictionary(
id => id,
_ => (Dictionary<string, object>)new Dictionary<string, object>
{
{ "IsActive", false }
});
batchResults = await salesforceClient.BatchUpdateEntitiesAsync(destinationEntity, deactivateUpdates);
break;
case DeletionAction.Mark:
batchResults = await salesforceClient.BatchPatchSingleFieldAsync(
destinationEntity, entityIds, options.MarkField!, options.MarkValue!);
break;
default:
_logger.LogWarning("DELETION SYNC: azione non supportata: {Action}", options.Action);
foreach (var d in pendingDeletions)
{
result.SyncErrors++;
result.Errors.Add($"KeyValue: {d.KeyValue} - Azione non supportata: {options.Action}");
}
return;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "DELETION SYNC: errore nell'esecuzione del batch Salesforce");
foreach (var d in pendingDeletions)
{
result.SyncErrors++;
result.Errors.Add($"KeyValue: {d.KeyValue} - {ex.Message}");
}
return;
}
// Aggiorna lo stato delle cancellazioni in DB in parallelo per i record sincronizzati con successo
var markSyncedTasks = new List<Task>();
foreach (var br in batchResults)
{
if (!deletionsById.TryGetValue(br.EntityId ?? string.Empty, out var deletion))
continue;
if (br.Success)
{
result.DeletedRecordsSynced++;
markSyncedTasks.Add(_credentialService.MarkDeletionSyncedAsync(deletion.Id));
_logger.LogDebug(
"DELETION SYNC: KeyValue={KeyValue}, DestinationId={DestinationId}, Action={Action} OK",
deletion.KeyValue, deletion.DestinationId, options.Action);
}
else
{
result.SyncErrors++;
var msg = $"KeyValue: {deletion.KeyValue} - {br.ErrorMessage ?? "Unknown error"}";
result.Errors.Add(msg);
_logger.LogWarning("DELETION SYNC fallita: {Msg}", msg);
}
}
if (markSyncedTasks.Count > 0)
await Task.WhenAll(markSyncedTasks);
}
/// <summary>
/// Fallback sequenziale per client REST non Salesforce.
/// </summary>
private async Task ExecuteSequentialDeletionsAsync(
IRestServiceClient restClient,
string destinationEntity,
List<KeyAssociation> pendingDeletions,
DeletionSyncOptions options,
DeletionSyncResult result)
{
foreach (var deletion in pendingDeletions)
{
try
{
bool syncSuccess = false;
string errorMessage = "";
switch (options.Action)
{
case DeletionAction.Delete:
syncSuccess = await DeleteRecordAsync(restClient, destinationEntity, deletion.DestinationId);
break;
case DeletionAction.Deactivate:
syncSuccess = await DeactivateRecordAsync(restClient, destinationEntity, deletion.DestinationId);
break;
case DeletionAction.Mark:
if (string.IsNullOrEmpty(options.MarkField) || string.IsNullOrEmpty(options.MarkValue))
{
errorMessage = "MarkField e MarkValue devono essere specificati per DeletionAction.Mark";
_logger.LogWarning(errorMessage);
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {errorMessage}");
continue;
}
syncSuccess = await MarkRecordAsync(restClient, destinationEntity, deletion.DestinationId,
options.MarkField, options.MarkValue);
break;
default:
errorMessage = $"Azione di cancellazione non supportata: {options.Action}";
_logger.LogWarning(errorMessage);
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {errorMessage}");
continue;
}
if (syncSuccess)
{
await _credentialService.MarkDeletionSyncedAsync(deletion.Id);
result.DeletedRecordsSynced++;
}
else
{
result.SyncErrors++;
result.Errors.Add($"Errore nella sincronizzazione della cancellazione per KeyValue: {deletion.KeyValue}");
}
}
catch (Exception ex)
{
result.SyncErrors++;
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {ex.Message}");
_logger.LogError(ex, "Errore nella sincronizzazione della cancellazione per {KeyValue}", deletion.KeyValue);
}
}
}
/// <summary> /// <summary>
/// Elimina fisicamente un record dalla destinazione /// Elimina fisicamente un record dalla destinazione
/// </summary> /// </summary>
@@ -895,10 +895,45 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
// Crea lista indicizzata per mantenere il record number // Crea lista indicizzata per mantenere il record number
var indexedRecords = sourceRecords.Select((record, index) => new { Record = record, RecordNumber = index + 1 }).ToList(); var indexedRecords = sourceRecords.Select((record, index) => new { Record = record, RecordNumber = index + 1 }).ToList();
_logger.LogInformation("COMPOSITE SCHEDULED: Inizio analisi parallela di {RecordCount} record", indexedRecords.Count); _logger.LogInformation("COMPOSITE SCHEDULED: Inizio analisi di {RecordCount} record", indexedRecords.Count);
var analysisStartTime = DateTime.UtcNow; var analysisStartTime = DateTime.UtcNow;
// Processa tutti i record in parallelo // === STEP A: Bulk Pre-Discovery (1 query SQLite + poche SOQL IN invece di 2N+N) ===
// Pre-calcolo locale: source key per ogni record (operazione thread-safe)
var sourceKeyByRecordIndex = new Dictionary<int, string>(indexedRecords.Count);
foreach (var idx in indexedRecords)
{
var key = GenerateSourceKey(idx.Record, profile.SourceKeyField);
if (!string.IsNullOrEmpty(key))
sourceKeyByRecordIndex[idx.RecordNumber] = key;
}
Dictionary<string, KeyAssociation> associationsByKey = new(StringComparer.Ordinal);
if (currentUseRecordAssociations && !string.IsNullOrEmpty(profile.SourceKeyField) && sourceKeyByRecordIndex.Count > 0)
{
var commonRequest = new PreDiscoveryRequest
{
SourceKeyField = profile.SourceKeyField,
DestinationEntity = currentEntityName,
CredentialName = currentCredentialName,
DestinationKeyField = "Id",
FieldMappings = fieldMappings,
RestClient = restClient,
EnablePreDiscovery = true,
UseParallelMethod = true,
IsScheduledTransfer = true,
SourceType = profile.SourceType
};
associationsByKey = await _associationService.BatchFindOrCreateAssociationsAsync(
sourceKeyByRecordIndex.Values, commonRequest);
_logger.LogInformation("COMPOSITE SCHEDULED: Bulk Pre-Discovery completata - {Found}/{Total} associazioni risolte",
associationsByKey.Count, sourceKeyByRecordIndex.Count);
}
// === STEP B: Analisi locale parallela per decidere create/update/skip ===
// Nessuna chiamata DB o REST in questo loop — solo memoria.
var processingTasks = indexedRecords.Select(async indexedRecord => var processingTasks = indexedRecords.Select(async indexedRecord =>
{ {
try try
@@ -916,49 +951,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
// Analizza le associazioni per capire se aggiornare, creare o saltare // Analizza le associazioni per capire se aggiornare, creare o saltare
if (currentUseRecordAssociations && !string.IsNullOrEmpty(sourceKey)) if (currentUseRecordAssociations && !string.IsNullOrEmpty(sourceKey))
{ {
_logger.LogDebug("COMPOSITE SCHEDULED: Cerco associazione per KeyValue: '{KeyValue}', Entity: '{Entity}', Credential: '{Credential}'", associationsByKey.TryGetValue(sourceKey, out var existingAssociation);
sourceKey, currentEntityName, currentCredentialName);
// Cerca associazione esistente usando il metodo parallelo
var existingAssociation = await _dataConnectionCredentialService.FindKeyAssociationByValueParallelAsync(
sourceKey, currentEntityName, currentCredentialName);
// FALLBACK: Se non troviamo l'associazione con tutti i parametri, proviamo solo con il KeyValue
if (existingAssociation == null)
{
existingAssociation = await _dataConnectionCredentialService.FindKeyAssociationByValueParallelAsync(sourceKey);
if (existingAssociation != null)
{
// Verifica compatibilità
if (existingAssociation.DestinationEntity != currentEntityName ||
existingAssociation.RestCredentialName != currentCredentialName)
{
existingAssociation = null;
}
}
}
// 🔍 PRE-DISCOVERY: Usa il servizio centralizzato
if (existingAssociation == null && !string.IsNullOrEmpty(profile.SourceKeyField))
{
var preDiscoveryRequest = new PreDiscoveryRequest
{
SourceKey = sourceKey,
SourceKeyField = profile.SourceKeyField,
DestinationEntity = currentEntityName,
CredentialName = currentCredentialName,
DestinationKeyField = "Id",
FieldMappings = fieldMappings,
RestClient = restClient,
CurrentDataHash = currentDataHash,
EnablePreDiscovery = true,
UseParallelMethod = true, // Usa metodi paralleli thread-safe
IsScheduledTransfer = true,
SourceType = profile.SourceType
};
existingAssociation = await _associationService.FindOrCreateAssociationAsync(preDiscoveryRequest);
}
if (existingAssociation != null && existingAssociation.IsActive) if (existingAssociation != null && existingAssociation.IsActive)
{ {
+164
View File
@@ -0,0 +1,164 @@
# Pubblicazione Data-Coupler: Guida 32-bit e 64-bit
## Perché è importante scegliere la piattaforma target
Alcune tecnologie di connessione sono vincolate alla piattaforma (32-bit o 64-bit):
| Tecnologia | Supporto | Note |
|---|---|---|
| **VFPOLEDB.1** (Visual FoxPro) | **Solo 32-bit** | Driver COM 32-bit, incompatibile con processi 64-bit |
| **Microsoft.ACE.OLEDB.12.0** (Access 2010) | 32-bit o 64-bit (match) | Installa la versione corrispondente all'app |
| **Microsoft.Jet.OLEDB.4.0** | **Solo 32-bit** | Driver legacy |
| ODBC generico | Dipende dal driver | Usa Gestore ODBC a 64-bit per driver 64-bit |
| SQL Server, MySQL, PostgreSQL, ecc. | 64-bit (consigliato) | Driver nativi .NET, nessun vincolo |
---
## Comandi di Pubblicazione
### 1. Pubblicazione Windows 64-bit (default, consigliato per SQL Server/MySQL/API REST)
```powershell
dotnet publish Data_Coupler/Data_Coupler.csproj `
--configuration Release `
--runtime win-x64 `
--self-contained true `
--output ./publish/win-x64
```
**Usa per**: SQL Server, MySQL, PostgreSQL, Oracle, REST API, ODBC 64-bit
**Non usare per**: VFPOLEDB, Jet 4.0
---
### 2. Pubblicazione Windows 32-bit (richiesta per Visual FoxPro / VFPOLEDB)
```powershell
dotnet publish Data_Coupler/Data_Coupler.csproj `
--configuration Release `
--runtime win-x86 `
--self-contained true `
--output ./publish/win-x86
```
**Usa per**: VFPOLEDB.1 (Visual FoxPro 8/9), Microsoft.Jet.OLEDB.4.0, driver OLE DB legacy 32-bit
**Nota**: Il processo sarà 32-bit — massima RAM ≈ 4GB.
---
### 3. Pubblicazione Linux x64
```powershell
dotnet publish Data_Coupler/Data_Coupler.csproj `
--configuration Release `
--runtime linux-x64 `
--self-contained true `
--output ./publish/linux-x64
```
**Attenzione**: OLE DB e ODBC (drivers Windows) **non sono supportati** su Linux.
Su Linux sono disponibili solo: SQL Server, MySQL, PostgreSQL, Oracle, SQLite, DB2, SAP HANA, REST API.
---
### 4. Pubblicazione macOS (Intel)
```powershell
dotnet publish Data_Coupler/Data_Coupler.csproj `
--configuration Release `
--runtime osx-x64 `
--self-contained true `
--output ./publish/osx-x64
```
---
### 5. Pubblicazione macOS (Apple Silicon - ARM64)
```powershell
dotnet publish Data_Coupler/Data_Coupler.csproj `
--configuration Release `
--runtime osx-arm64 `
--self-contained true `
--output ./publish/osx-arm64
```
---
## Pubblicazione Framework-Dependent (senza runtime incluso)
Se .NET 9 è già installato sul server target:
```powershell
# Windows (framework-dependent, lascia a .NET la scelta della bitness)
dotnet publish Data_Coupler/Data_Coupler.csproj `
--configuration Release `
--output ./publish/framework-dependent
# Forzare 32-bit anche in framework-dependent (per VFP):
# Compilare il progetto con PlatformTarget = x86 o usare --runtime win-x86
```
---
## Setup per Visual FoxPro (VFPOLEDB.1)
### Prerequisiti
1. **Driver VFPOLEDB installato** (32-bit):
- Download: [Microsoft OLE DB Provider for Visual FoxPro 9.0 SP2](https://www.microsoft.com/en-us/download/details.aspx?id=14839)
- Verifica installazione: `HKEY_CLASSES_ROOT\VFPOLEDB.1` deve esistere nel registro
2. **Applicazione pubblicata come 32-bit** (`--runtime win-x86`)
3. **Connection string esempio VFP**:
```
Provider=VFPOLEDB.1;Data Source=C:\VFP\Database\miodb.dbc;Collating Sequence=machine;
```
Per tabelle free (.dbf):
```
Provider=VFPOLEDB.1;Data Source=C:\VFP\Tabelle\;Collating Sequence=machine;
```
### Verifica Rapida in PowerShell
```powershell
# Verifica driver VFP installato
Get-Item "HKCR:\VFPOLEDB.1" -ErrorAction SilentlyContinue
# Verifica processo 32-bit in esecuzione
[System.Environment]::Is64BitProcess # deve restituire False
```
---
## Docker
Per Docker con VFP (non consigliato — driver COM Windows-only):
```dockerfile
# Nel Dockerfile, non è possibile usare VFPOLEDB su Linux container
# Usare Windows Container (opzione Dockerfile.windows)
```
Per **Windows Container** con supporto 32-bit, modifica il `Dockerfile.windows`:
```dockerfile
# Usa immagine Windows nano server
FROM mcr.microsoft.com/windows/nanoserver:ltsc2022
# Copia publish win-x86
COPY ./publish/win-x86 /app
```
---
## Riepilogo Rapido
| Scenario | Comando |
|---|---|
| Solo database SQL + REST API | `--runtime win-x64` |
| Visual FoxPro / OLE DB legacy | `--runtime win-x86` |
| Server Linux | `--runtime linux-x64` |
| macOS Intel | `--runtime osx-x64` |
| macOS Apple Silicon | `--runtime osx-arm64` |