344853fde9
## 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
415 lines
15 KiB
C#
415 lines
15 KiB
C#
using CredentialManager.Models;
|
|
using DataConnection.CredentialManagement.Interfaces;
|
|
using DataConnection.REST.Implementations;
|
|
using DataConnection.REST.Interfaces;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Data_Coupler.Services;
|
|
|
|
/// <summary>
|
|
/// Interfaccia per il servizio di sincronizzazione delle cancellazioni
|
|
/// </summary>
|
|
public interface IDeletionSyncService
|
|
{
|
|
/// <summary>
|
|
/// Sincronizza le cancellazioni dalla sorgente alla destinazione
|
|
/// </summary>
|
|
Task<DeletionSyncResult> SyncDeletionsAsync(
|
|
List<string> currentSourceKeyValues,
|
|
string destinationEntity,
|
|
string restCredentialName,
|
|
IRestServiceClient restClient,
|
|
DeletionSyncOptions options);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Servizio per la sincronizzazione delle cancellazioni dalla sorgente alla destinazione
|
|
/// </summary>
|
|
public class DeletionSyncService : IDeletionSyncService
|
|
{
|
|
private readonly IDataConnectionCredentialService _credentialService;
|
|
private readonly ILogger<DeletionSyncService> _logger;
|
|
|
|
public DeletionSyncService(
|
|
IDataConnectionCredentialService credentialService,
|
|
ILogger<DeletionSyncService> logger)
|
|
{
|
|
_credentialService = credentialService ?? throw new ArgumentNullException(nameof(credentialService));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sincronizza le cancellazioni dalla sorgente alla destinazione
|
|
/// </summary>
|
|
public async Task<DeletionSyncResult> SyncDeletionsAsync(
|
|
List<string> currentSourceKeyValues,
|
|
string destinationEntity,
|
|
string restCredentialName,
|
|
IRestServiceClient restClient,
|
|
DeletionSyncOptions options)
|
|
{
|
|
var result = new DeletionSyncResult
|
|
{
|
|
StartTime = DateTime.Now
|
|
};
|
|
|
|
try
|
|
{
|
|
_logger.LogInformation("Inizio sincronizzazione cancellazioni per {Entity} - {Credential}",
|
|
destinationEntity, restCredentialName);
|
|
|
|
// Step 1: Marca le associazioni come cancellate se non sono più presenti nella sorgente
|
|
var markedCount = await _credentialService.MarkDeletedAssociationsAsync(
|
|
currentSourceKeyValues, destinationEntity, restCredentialName);
|
|
|
|
result.DeletedRecordsDetected = markedCount;
|
|
_logger.LogInformation("Rilevati {Count} record cancellati dalla sorgente", markedCount);
|
|
|
|
if (markedCount == 0)
|
|
{
|
|
result.IsSuccess = true;
|
|
result.Message = "Nessun record cancellato rilevato";
|
|
result.EndTime = DateTime.Now;
|
|
return result;
|
|
}
|
|
|
|
// Step 2: Ottieni le cancellazioni in attesa di sincronizzazione
|
|
var pendingDeletions = await _credentialService.GetPendingDeletionsAsync(
|
|
destinationEntity, restCredentialName);
|
|
|
|
_logger.LogInformation("Trovate {Count} cancellazioni in attesa di sincronizzazione",
|
|
pendingDeletions.Count);
|
|
|
|
// Step 3: Esegui le cancellazioni nella destinazione.
|
|
// 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)
|
|
{
|
|
await ExecuteBatchedSalesforceDeletionsAsync(
|
|
salesforceClient, destinationEntity, pendingDeletions, options, result);
|
|
}
|
|
else
|
|
{
|
|
await ExecuteSequentialDeletionsAsync(
|
|
restClient, destinationEntity, pendingDeletions, options, result);
|
|
}
|
|
|
|
result.IsSuccess = result.SyncErrors == 0;
|
|
result.Message = result.IsSuccess
|
|
? $"Sincronizzazione completata: {result.DeletedRecordsSynced} record cancellati"
|
|
: $"Sincronizzazione completata con errori: {result.DeletedRecordsSynced} sincronizzati, {result.SyncErrors} errori";
|
|
|
|
_logger.LogInformation(result.Message);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result.IsSuccess = false;
|
|
result.Message = $"Errore durante la sincronizzazione delle cancellazioni: {ex.Message}";
|
|
result.Errors.Add(ex.Message);
|
|
_logger.LogError(ex, "Errore durante la sincronizzazione delle cancellazioni");
|
|
}
|
|
|
|
result.EndTime = DateTime.Now;
|
|
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>
|
|
/// Elimina fisicamente un record dalla destinazione
|
|
/// </summary>
|
|
private async Task<bool> DeleteRecordAsync(
|
|
IRestServiceClient restClient,
|
|
string entityName,
|
|
string entityId)
|
|
{
|
|
try
|
|
{
|
|
return await restClient.DeleteEntityAsync(entityName, entityId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nell'eliminazione del record {EntityId} dall'entità {Entity}",
|
|
entityId, entityName);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marca un record come inattivo nella destinazione
|
|
/// </summary>
|
|
private async Task<bool> DeactivateRecordAsync(
|
|
IRestServiceClient restClient,
|
|
string entityName,
|
|
string entityId)
|
|
{
|
|
try
|
|
{
|
|
var updateData = new Dictionary<string, object>
|
|
{
|
|
{ "IsActive", false },
|
|
{ "Active", false } // Prova entrambi i campi comuni
|
|
};
|
|
|
|
var result = await restClient.UpdateEntityAsync(entityName, entityId, updateData);
|
|
return result != null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nella disattivazione del record {EntityId} dall'entità {Entity}",
|
|
entityId, entityName);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Imposta un campo personalizzato per marcare un record come cancellato
|
|
/// </summary>
|
|
private async Task<bool> MarkRecordAsync(
|
|
IRestServiceClient restClient,
|
|
string entityName,
|
|
string entityId,
|
|
string markField,
|
|
string markValue)
|
|
{
|
|
try
|
|
{
|
|
var updateData = new Dictionary<string, object>
|
|
{
|
|
{ markField, markValue }
|
|
};
|
|
|
|
var result = await restClient.UpdateEntityAsync(entityName, entityId, updateData);
|
|
return result != null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Errore nella marcatura del record {EntityId} dall'entità {Entity}",
|
|
entityId, entityName);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opzioni per la sincronizzazione delle cancellazioni
|
|
/// </summary>
|
|
public class DeletionSyncOptions
|
|
{
|
|
public DeletionAction Action { get; set; } = DeletionAction.Delete;
|
|
public string? MarkField { get; set; }
|
|
public string? MarkValue { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Azione da eseguire per i record cancellati
|
|
/// </summary>
|
|
public enum DeletionAction
|
|
{
|
|
/// <summary>
|
|
/// Elimina fisicamente il record
|
|
/// </summary>
|
|
Delete,
|
|
|
|
/// <summary>
|
|
/// Marca il record come inattivo
|
|
/// </summary>
|
|
Deactivate,
|
|
|
|
/// <summary>
|
|
/// Imposta un campo personalizzato
|
|
/// </summary>
|
|
Mark
|
|
}
|
|
|
|
/// <summary>
|
|
/// Risultato della sincronizzazione delle cancellazioni
|
|
/// </summary>
|
|
public class DeletionSyncResult
|
|
{
|
|
public bool IsSuccess { get; set; }
|
|
public string Message { get; set; } = "";
|
|
public int DeletedRecordsDetected { get; set; }
|
|
public int DeletedRecordsSynced { get; set; }
|
|
public int SyncErrors { get; set; }
|
|
public List<string> Errors { get; set; } = new();
|
|
public DateTime StartTime { get; set; }
|
|
public DateTime EndTime { get; set; }
|
|
public TimeSpan Duration => EndTime - StartTime;
|
|
}
|