Files
Data-Coupler/CredentialManager/Services/KeyAssociationService.cs
T
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

1058 lines
43 KiB
C#

using CredentialManager.Data;
using CredentialManager.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CredentialManager.Services;
/// <summary>
/// Servizio per la gestione delle associazioni basate sui valori delle chiavi
/// </summary>
public class KeyAssociationService : IKeyAssociationService
{
private readonly CredentialDbContext _context;
private readonly ILogger<KeyAssociationService> _logger;
public KeyAssociationService(
CredentialDbContext context,
ILogger<KeyAssociationService> logger)
{
_context = context;
_logger = logger;
}
public async Task<int> SaveAssociationAsync(KeyAssociation association)
{
// Cattura i valori critici all'inizio per evitare race conditions
var keyValue = association.KeyValue;
var destinationEntity = association.DestinationEntity;
var destinationId = association.DestinationId;
var restCredentialName = association.RestCredentialName;
var sourceKeyField = association.SourceKeyField;
var destinationKeyField = association.DestinationKeyField;
var additionalInfo = association.AdditionalInfo;
var dataHash = association.Data_Hash;
var currentTime = DateTime.UtcNow;
try
{
_logger.LogInformation("DEBUG: Tentativo salvataggio associazione - KeyValue: '{KeyValue}', DestinationEntity: '{DestinationEntity}', DestinationId: '{DestinationId}', RestCredentialName: '{RestCredentialName}'",
keyValue, destinationEntity, destinationId, restCredentialName);
// Implementazione thread-safe usando upsert pattern
// Prima tenta di aggiornare un record esistente
var rowsAffected = await _context.Database.ExecuteSqlRawAsync(@"
UPDATE KeyAssociations
SET DestinationId = {0},
SourceKeyField = {1},
DestinationKeyField = {2},
UpdatedAt = {3},
LastVerifiedAt = {4},
AdditionalInfo = {5},
Data_Hash = {6}
WHERE KeyValue = {7}
AND DestinationEntity = {8}
AND RestCredentialName = {9}
AND IsActive = 1",
destinationId, sourceKeyField, destinationKeyField, currentTime, currentTime, additionalInfo ?? (object)DBNull.Value, dataHash ?? (object)DBNull.Value,
keyValue, destinationEntity, restCredentialName);
if (rowsAffected > 0)
{
// Recupera l'ID dell'associazione aggiornata
var existing = await _context.KeyAssociations
.FirstOrDefaultAsync(ka =>
ka.KeyValue == keyValue &&
ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName &&
ka.IsActive);
if (existing != null)
{
// Aggiorna le informazioni sulle sorgenti usando l'entità tracciata
UpdateSourcesInfo(existing, association);
await _context.SaveChangesAsync();
_logger.LogInformation("Associazione aggiornata: KeyValue={KeyValue} -> {DestinationEntity}/{DestinationId}",
keyValue, destinationEntity, destinationId);
return existing.Id;
}
}
// Se l'aggiornamento non ha modificato nessuna riga, tenta l'inserimento
try
{
var newAssociation = new KeyAssociation
{
KeyValue = keyValue,
SourceKeyField = sourceKeyField,
DestinationKeyField = destinationKeyField,
DestinationEntity = destinationEntity,
DestinationId = destinationId,
RestCredentialName = restCredentialName,
CreatedAt = currentTime,
LastVerifiedAt = currentTime,
AdditionalInfo = additionalInfo,
Data_Hash = dataHash,
IsActive = true
};
_context.KeyAssociations.Add(newAssociation);
await _context.SaveChangesAsync();
_logger.LogInformation("Nuova associazione creata: KeyValue={KeyValue} -> {DestinationEntity}/{DestinationId}",
keyValue, destinationEntity, destinationId);
return newAssociation.Id;
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message?.Contains("UNIQUE constraint failed") == true)
{
// Race condition: un altro thread ha inserito la stessa associazione
// Ritenta la ricerca e aggiornamento
_logger.LogDebug("Race condition rilevata durante inserimento, ritento con aggiornamento per KeyValue: {KeyValue}", keyValue);
var existing = await _context.KeyAssociations
.FirstOrDefaultAsync(ka =>
ka.KeyValue == keyValue &&
ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName &&
ka.IsActive);
if (existing != null)
{
// Aggiorna l'associazione trovata
existing.DestinationId = destinationId;
existing.SourceKeyField = sourceKeyField;
existing.DestinationKeyField = destinationKeyField;
existing.UpdatedAt = currentTime;
existing.LastVerifiedAt = currentTime;
existing.AdditionalInfo = additionalInfo;
existing.Data_Hash = dataHash;
UpdateSourcesInfo(existing, association);
_context.KeyAssociations.Update(existing);
await _context.SaveChangesAsync();
_logger.LogInformation("Associazione aggiornata dopo race condition: KeyValue={KeyValue} -> {DestinationEntity}/{DestinationId}",
keyValue, destinationEntity, destinationId);
return existing.Id;
}
// Se non riusciamo a trovare neanche l'associazione creata da un altro thread, rilancia l'eccezione
throw;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel salvare l'associazione: KeyValue={KeyValue} -> {DestinationEntity}",
keyValue, destinationEntity);
throw;
}
}
/// <summary>
/// Versione thread-safe del SaveAssociationAsync che utilizza un DbContext separato per operazioni parallele
/// </summary>
public async Task<int> SaveAssociationParallelAsync(KeyAssociation association)
{
// Cattura i valori critici all'inizio per evitare race conditions
var keyValue = association.KeyValue;
var destinationEntity = association.DestinationEntity;
var destinationId = association.DestinationId;
var restCredentialName = association.RestCredentialName;
var sourceKeyField = association.SourceKeyField;
var destinationKeyField = association.DestinationKeyField;
var mappedDestinationField = association.MappedDestinationField; // AGGIUNTO
var additionalInfo = association.AdditionalInfo;
var dataHash = association.Data_Hash;
var currentTime = DateTime.UtcNow;
// Crea un nuovo DbContext per questa operazione parallela
var options = new DbContextOptionsBuilder<CredentialDbContext>()
.UseSqlite(_context.Database.GetConnectionString())
.Options;
using var parallelContext = new CredentialDbContext(options);
try
{
_logger.LogDebug("PARALLEL: Tentativo salvataggio associazione - KeyValue: '{KeyValue}', DestinationEntity: '{DestinationEntity}', DestinationId: '{DestinationId}', RestCredentialName: '{RestCredentialName}', MappedField: '{MappedField}'",
keyValue, destinationEntity, destinationId, restCredentialName, mappedDestinationField ?? "NULL");
// Implementazione thread-safe usando upsert pattern con DbContext separato
// Prima tenta di aggiornare un record esistente
var rowsAffected = await parallelContext.Database.ExecuteSqlRawAsync(@"
UPDATE KeyAssociations
SET DestinationId = {0},
SourceKeyField = {1},
DestinationKeyField = {2},
UpdatedAt = {3},
LastVerifiedAt = {4},
AdditionalInfo = {5},
Data_Hash = {6},
MappedDestinationField = {7}
WHERE KeyValue = {8}
AND DestinationEntity = {9}
AND RestCredentialName = {10}
AND IsActive = 1",
destinationId, sourceKeyField, destinationKeyField, currentTime, currentTime, additionalInfo ?? (object)DBNull.Value, dataHash ?? (object)DBNull.Value, mappedDestinationField ?? (object)DBNull.Value,
keyValue, destinationEntity, restCredentialName);
if (rowsAffected > 0)
{
// Recupera l'ID dell'associazione aggiornata
var existing = await parallelContext.KeyAssociations
.FirstOrDefaultAsync(ka =>
ka.KeyValue == keyValue &&
ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName &&
ka.IsActive);
if (existing != null)
{
// Aggiorna le informazioni sulle sorgenti usando l'entità tracciata
UpdateSourcesInfo(existing, association);
await parallelContext.SaveChangesAsync();
_logger.LogDebug("PARALLEL: Associazione aggiornata: KeyValue={KeyValue} -> {DestinationEntity}/{DestinationId}",
keyValue, destinationEntity, destinationId);
return existing.Id;
}
}
// Se l'aggiornamento non ha modificato nessuna riga, tenta l'inserimento
try
{
var newAssociation = new KeyAssociation
{
KeyValue = keyValue,
SourceKeyField = sourceKeyField,
DestinationKeyField = destinationKeyField,
MappedDestinationField = mappedDestinationField, // AGGIUNTO
DestinationEntity = destinationEntity,
DestinationId = destinationId,
RestCredentialName = restCredentialName,
CreatedAt = currentTime,
LastVerifiedAt = currentTime,
AdditionalInfo = additionalInfo,
Data_Hash = dataHash,
IsActive = true
};
parallelContext.KeyAssociations.Add(newAssociation);
await parallelContext.SaveChangesAsync();
_logger.LogDebug("PARALLEL: Nuova associazione creata: KeyValue={KeyValue} -> {DestinationEntity}/{DestinationId}, MappedField={MappedField}",
keyValue, destinationEntity, destinationId, mappedDestinationField ?? "NULL");
return newAssociation.Id;
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException dbEx) when (dbEx.InnerException?.Message?.Contains("UNIQUE constraint failed") == true)
{
// Race condition: un altro thread ha inserito la stessa associazione
// Ritenta la ricerca e aggiornamento
_logger.LogDebug("PARALLEL: Race condition rilevata durante inserimento, ritento con aggiornamento per KeyValue: {KeyValue}", keyValue);
var existing = await parallelContext.KeyAssociations
.FirstOrDefaultAsync(ka =>
ka.KeyValue == keyValue &&
ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName &&
ka.IsActive);
if (existing != null)
{
// Aggiorna l'associazione trovata
existing.DestinationId = destinationId;
existing.SourceKeyField = sourceKeyField;
existing.DestinationKeyField = destinationKeyField;
existing.UpdatedAt = currentTime;
existing.LastVerifiedAt = currentTime;
existing.AdditionalInfo = additionalInfo;
UpdateSourcesInfo(existing, association);
parallelContext.KeyAssociations.Update(existing);
await parallelContext.SaveChangesAsync();
_logger.LogDebug("PARALLEL: Associazione aggiornata dopo race condition: KeyValue={KeyValue} -> {DestinationEntity}/{DestinationId}",
keyValue, destinationEntity, destinationId);
return existing.Id;
}
// Se non riusciamo a trovare neanche l'associazione creata da un altro thread, rilancia l'eccezione
throw;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "PARALLEL: Errore nel salvare l'associazione: KeyValue={KeyValue} -> {DestinationEntity}",
keyValue, destinationEntity);
throw;
}
}
/// <summary>
/// Versione thread-safe per operazioni parallele - Find association by key value
/// </summary>
public async Task<KeyAssociation?> FindAssociationByKeyValueParallelAsync(string keyValue, string destinationEntity, string restCredentialName)
{
var options = new DbContextOptionsBuilder<CredentialDbContext>()
.UseSqlite(_context.Database.GetConnectionString())
.Options;
using var parallelContext = new CredentialDbContext(options);
try
{
_logger.LogDebug("PARALLEL: Ricerca associazione con parametri - KeyValue: '{KeyValue}', DestinationEntity: '{DestinationEntity}', RestCredentialName: '{RestCredentialName}'",
keyValue, destinationEntity, restCredentialName);
var result = await parallelContext.KeyAssociations
.FirstOrDefaultAsync(ka =>
ka.KeyValue == keyValue &&
ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName &&
ka.IsActive);
_logger.LogDebug("PARALLEL: Risultato ricerca associazione: {Found}. ID: {Id}, DestinationId: '{DestinationId}'",
result != null, result?.Id, result?.DestinationId);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "PARALLEL: Errore nella ricerca dell'associazione: KeyValue={KeyValue} -> {DestinationEntity}",
keyValue, destinationEntity);
throw;
}
}
/// <summary>
/// Versione thread-safe per operazioni parallele - Find association by key value only
/// </summary>
public async Task<KeyAssociation?> FindAssociationByKeyValueParallelAsync(string keyValue)
{
var options = new DbContextOptionsBuilder<CredentialDbContext>()
.UseSqlite(_context.Database.GetConnectionString())
.Options;
using var parallelContext = new CredentialDbContext(options);
try
{
return await parallelContext.KeyAssociations
.Where(ka => ka.KeyValue == keyValue && ka.IsActive)
.OrderByDescending(ka => ka.UpdatedAt ?? ka.CreatedAt)
.FirstOrDefaultAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "PARALLEL: Errore nella ricerca dell'associazione per KeyValue={KeyValue}", keyValue);
throw;
}
}
/// <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>
/// Versione thread-safe per operazioni parallele - Delete association
/// </summary>
public async Task<bool> DeleteAssociationParallelAsync(int id)
{
var options = new DbContextOptionsBuilder<CredentialDbContext>()
.UseSqlite(_context.Database.GetConnectionString())
.Options;
using var parallelContext = new CredentialDbContext(options);
try
{
var association = await parallelContext.KeyAssociations.FindAsync(id);
if (association == null)
{
_logger.LogWarning("PARALLEL: Associazione con ID {Id} non trovata per l'eliminazione", id);
return false;
}
parallelContext.KeyAssociations.Remove(association);
await parallelContext.SaveChangesAsync();
_logger.LogDebug("PARALLEL: Associazione eliminata: ID {Id}", id);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "PARALLEL: Errore nell'eliminazione dell'associazione: ID {Id}", id);
throw;
}
}
/// <summary>
/// Versione thread-safe per operazioni parallele - Salva una associazione con parametri semplici
/// </summary>
public async Task<bool> SaveAssociationParallelAsync(string keyValue, string destinationEntity, string destinationId, string restCredentialName)
{
var options = new DbContextOptionsBuilder<CredentialDbContext>()
.UseSqlite(_context.Database.GetConnectionString())
.Options;
using var parallelContext = new CredentialDbContext(options);
try
{
_logger.LogDebug("PARALLEL: Salvataggio associazione - KeyValue: '{KeyValue}', DestinationEntity: '{DestinationEntity}', DestinationId: '{DestinationId}', RestCredentialName: '{RestCredentialName}'",
keyValue, destinationEntity, destinationId, restCredentialName);
var currentTime = DateTime.UtcNow;
// Implementazione thread-safe usando upsert pattern
var rowsAffected = await parallelContext.Database.ExecuteSqlRawAsync(@"
UPDATE KeyAssociations
SET DestinationId = {0},
UpdatedAt = {1},
LastVerifiedAt = {2},
IsActive = 1
WHERE KeyValue = {3}
AND DestinationEntity = {4}
AND RestCredentialName = {5}",
destinationId, currentTime, currentTime, keyValue, destinationEntity, restCredentialName);
if (rowsAffected == 0)
{
// Se nessun record è stato aggiornato, inserisci un nuovo record
await parallelContext.Database.ExecuteSqlRawAsync(@"
INSERT INTO KeyAssociations
(KeyValue, DestinationEntity, DestinationId, RestCredentialName, CreatedAt, UpdatedAt, LastVerifiedAt, IsActive)
VALUES ({0}, {1}, {2}, {3}, {4}, {5}, {6}, 1)",
keyValue, destinationEntity, destinationId, restCredentialName, currentTime, currentTime, currentTime);
_logger.LogDebug("PARALLEL: Nuova associazione creata: {KeyValue} -> {DestinationEntity}({DestinationId})",
keyValue, destinationEntity, destinationId);
}
else
{
_logger.LogDebug("PARALLEL: Associazione aggiornata: {KeyValue} -> {DestinationEntity}({DestinationId})",
keyValue, destinationEntity, destinationId);
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "PARALLEL: Errore nel salvare l'associazione: KeyValue={KeyValue} -> {DestinationEntity}",
keyValue, destinationEntity);
throw;
}
}
public async Task<KeyAssociation?> FindAssociationByKeyValueAsync(string keyValue, string destinationEntity, string restCredentialName)
{
try
{
_logger.LogInformation("DEBUG: Ricerca associazione con parametri - KeyValue: '{KeyValue}', DestinationEntity: '{DestinationEntity}', RestCredentialName: '{RestCredentialName}'",
keyValue, destinationEntity, restCredentialName);
var result = await _context.KeyAssociations
.FirstOrDefaultAsync(ka =>
ka.KeyValue == keyValue &&
ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName &&
ka.IsActive);
_logger.LogInformation("DEBUG: Risultato ricerca associazione: {Found}. ID: {Id}, DestinationId: '{DestinationId}'",
result != null, result?.Id, result?.DestinationId);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella ricerca dell'associazione: KeyValue={KeyValue} -> {DestinationEntity}",
keyValue, destinationEntity);
throw;
}
}
public async Task<KeyAssociation?> FindAssociationByKeyValueAsync(string keyValue)
{
try
{
return await _context.KeyAssociations
.Where(ka => ka.KeyValue == keyValue && ka.IsActive)
.OrderByDescending(ka => ka.UpdatedAt ?? ka.CreatedAt)
.FirstOrDefaultAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella ricerca dell'associazione per KeyValue={KeyValue}", keyValue);
throw;
}
}
public async Task<List<KeyAssociation>> GetAssociationsByDestinationAsync(string destinationEntity, string restCredentialName)
{
try
{
return await _context.KeyAssociations
.Where(ka => ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName &&
ka.IsActive)
.OrderByDescending(ka => ka.CreatedAt)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero delle associazioni per destinazione: {DestinationEntity} ({RestCredentialName})",
destinationEntity, restCredentialName);
throw;
}
}
public async Task<List<KeyAssociation>> GetAllActiveAssociationsAsync()
{
try
{
return await _context.KeyAssociations
.Where(ka => ka.IsActive)
.OrderByDescending(ka => ka.CreatedAt)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero di tutte le associazioni attive");
throw;
}
}
public async Task<List<KeyAssociation>> GetAllAssociationsAsync()
{
try
{
return await _context.KeyAssociations
.OrderByDescending(ka => ka.CreatedAt)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero di tutte le associazioni");
throw;
}
}
public async Task<bool> UpdateAssociationAsync(KeyAssociation association)
{
try
{
var existing = await _context.KeyAssociations.FindAsync(association.Id);
if (existing == null)
{
_logger.LogWarning("Associazione con ID {Id} non trovata per l'aggiornamento", association.Id);
return false;
}
existing.KeyValue = association.KeyValue;
existing.SourceKeyField = association.SourceKeyField;
existing.DestinationKeyField = association.DestinationKeyField;
existing.DestinationId = association.DestinationId;
existing.RestCredentialName = association.RestCredentialName;
existing.UpdatedAt = DateTime.UtcNow;
existing.LastVerifiedAt = association.LastVerifiedAt;
existing.AdditionalInfo = association.AdditionalInfo;
existing.SourcesInfo = association.SourcesInfo;
existing.IsActive = association.IsActive;
existing.Data_Hash = association.Data_Hash;
_context.KeyAssociations.Update(existing);
await _context.SaveChangesAsync();
_logger.LogInformation("Associazione aggiornata: ID {Id}", association.Id);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nell'aggiornamento dell'associazione: ID {Id}", association.Id);
throw;
}
}
public async Task<bool> DeactivateAssociationAsync(int id)
{
try
{
var association = await _context.KeyAssociations.FindAsync(id);
if (association == null)
{
_logger.LogWarning("Associazione con ID {Id} non trovata per la disattivazione", id);
return false;
}
association.IsActive = false;
association.UpdatedAt = DateTime.UtcNow;
_context.KeyAssociations.Update(association);
await _context.SaveChangesAsync();
_logger.LogInformation("Associazione disattivata: ID {Id}", id);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella disattivazione dell'associazione: ID {Id}", id);
throw;
}
}
public async Task<bool> DeleteAssociationAsync(int id)
{
try
{
var association = await _context.KeyAssociations.FindAsync(id);
if (association == null)
{
_logger.LogWarning("Associazione con ID {Id} non trovata per l'eliminazione", id);
return false;
}
_context.KeyAssociations.Remove(association);
await _context.SaveChangesAsync();
_logger.LogInformation("Associazione eliminata: ID {Id}", id);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nell'eliminazione dell'associazione: ID {Id}", id);
throw;
}
}
public async Task<int> CleanupOldAssociationsAsync(TimeSpan olderThan)
{
try
{
var cutoffDate = DateTime.UtcNow - olderThan;
var oldAssociations = await _context.KeyAssociations
.Where(ka => ka.CreatedAt < cutoffDate && !ka.IsActive)
.ToListAsync();
if (oldAssociations.Any())
{
_context.KeyAssociations.RemoveRange(oldAssociations);
await _context.SaveChangesAsync();
_logger.LogInformation("Pulite {Count} associazioni obsolete più vecchie di {Cutoff}",
oldAssociations.Count, cutoffDate);
}
return oldAssociations.Count;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella pulizia delle associazioni obsolete");
throw;
}
}
public async Task<int> ClearAssociationsAsync(string destinationEntity, string restCredentialName)
{
try
{
var associationsToDelete = await _context.KeyAssociations
.Where(ka => ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName)
.ToListAsync();
if (associationsToDelete.Any())
{
_context.KeyAssociations.RemoveRange(associationsToDelete);
await _context.SaveChangesAsync();
_logger.LogInformation("Eliminate {Count} associazioni per {DestinationEntity}",
associationsToDelete.Count, destinationEntity);
}
return associationsToDelete.Count;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella cancellazione delle associazioni per {DestinationEntity}",
destinationEntity);
throw;
}
}
public async Task<int> ClearAllAssociationsAsync()
{
try
{
var allAssociations = await _context.KeyAssociations.ToListAsync();
var count = allAssociations.Count;
if (allAssociations.Any())
{
_context.KeyAssociations.RemoveRange(allAssociations);
await _context.SaveChangesAsync();
_logger.LogWarning("Eliminate TUTTE le {Count} associazioni dal sistema", count);
}
return count;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella cancellazione di tutte le associazioni");
throw;
}
}
public async Task<bool> ValidateDestinationIdAsync(string destinationId, string destinationEntity, string restCredentialName)
{
// Questa implementazione base restituisce sempre true
// Dovrebbe essere estesa per verificare effettivamente l'esistenza nel sistema REST
try
{
// TODO: Implementare la logica di validazione effettiva con il servizio REST
// Per ora assumiamo che l'ID sia valido
_logger.LogDebug("Validazione ID destinazione {DestinationId} per entità {DestinationEntity} - Non implementata",
destinationId, destinationEntity);
return await Task.FromResult(true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella validazione dell'ID destinazione {DestinationId}", destinationId);
return false;
}
}
public async Task<List<KeyAssociation>> GetInvalidAssociationsAsync(string destinationEntity, string restCredentialName)
{
try
{
var associations = await _context.KeyAssociations
.Where(ka => ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName &&
ka.IsActive)
.ToListAsync();
var invalidAssociations = new List<KeyAssociation>();
// Verifica ogni associazione
foreach (var association in associations)
{
var isValid = await ValidateDestinationIdAsync(association.DestinationId, destinationEntity, restCredentialName);
if (!isValid)
{
invalidAssociations.Add(association);
}
}
_logger.LogInformation("Trovate {Invalid}/{Total} associazioni non valide per {DestinationEntity}",
invalidAssociations.Count, associations.Count, destinationEntity);
return invalidAssociations;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero delle associazioni non valide per {DestinationEntity}", destinationEntity);
throw;
}
}
public async Task<int> CleanupInvalidAssociationsAsync(string destinationEntity, string restCredentialName)
{
try
{
var invalidAssociations = await GetInvalidAssociationsAsync(destinationEntity, restCredentialName);
if (invalidAssociations.Any())
{
_context.KeyAssociations.RemoveRange(invalidAssociations);
await _context.SaveChangesAsync();
_logger.LogWarning("Eliminate {Count} associazioni non valide per {DestinationEntity}",
invalidAssociations.Count, destinationEntity);
}
return invalidAssociations.Count;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella pulizia delle associazioni non valide per {DestinationEntity}", destinationEntity);
throw;
}
}
public async Task<bool> UpdateLastVerifiedAsync(int id)
{
try
{
var association = await _context.KeyAssociations.FindAsync(id);
if (association == null)
{
_logger.LogWarning("Associazione con ID {Id} non trovata per l'aggiornamento della verifica", id);
return false;
}
association.LastVerifiedAt = DateTime.UtcNow;
_context.KeyAssociations.Update(association);
await _context.SaveChangesAsync();
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nell'aggiornamento della verifica per associazione: ID {Id}", id);
throw;
}
}
public async Task<AssociationStatistics> GetStatisticsAsync()
{
try
{
var allAssociations = await _context.KeyAssociations.ToListAsync();
var stats = new AssociationStatistics
{
TotalAssociations = allAssociations.Count,
ActiveAssociations = allAssociations.Count(a => a.IsActive),
InactiveAssociations = allAssociations.Count(a => !a.IsActive),
UniqueKeyValues = allAssociations.Select(a => a.KeyValue).Distinct().Count(),
UniqueDestinationEntities = allAssociations.Select(a => a.DestinationEntity).Distinct().Count(),
OldestAssociation = allAssociations.Any() ? allAssociations.Min(a => a.CreatedAt) : null,
NewestAssociation = allAssociations.Any() ? allAssociations.Max(a => a.CreatedAt) : null,
AssociationsByEntity = allAssociations
.GroupBy(a => a.DestinationEntity)
.ToDictionary(g => g.Key, g => g.Count())
};
return stats;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel calcolo delle statistiche delle associazioni");
throw;
}
}
private void UpdateSourcesInfo(KeyAssociation existing, KeyAssociation newAssociation)
{
try
{
var sourcesInfo = existing.SourcesInfo ?? "";
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm");
var newSourceInfo = $"{timestamp}: {newAssociation.SourceKeyField}";
if (!sourcesInfo.Contains(newSourceInfo))
{
existing.SourcesInfo = string.IsNullOrEmpty(sourcesInfo)
? newSourceInfo
: $"{sourcesInfo}; {newSourceInfo}";
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Errore nell'aggiornamento delle informazioni sulle sorgenti");
}
}
/// <summary>
/// Marca le associazioni come cancellate dalla sorgente se i loro KeyValue non sono presenti nella lista fornita
/// </summary>
public async Task<int> MarkDeletedAssociationsAsync(List<string> sourceKeyValues, string destinationEntity, string restCredentialName)
{
try
{
_logger.LogInformation("Verifica cancellazioni per {Entity} - {Credential}: {Count} chiavi sorgente attive",
destinationEntity, restCredentialName, sourceKeyValues.Count);
// Ottieni tutte le associazioni attive per questa destinazione
var existingAssociations = await _context.KeyAssociations
.Where(ka => ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName &&
ka.IsActive &&
!ka.IsSourceDeleted)
.ToListAsync();
_logger.LogInformation("Trovate {Count} associazioni attive esistenti", existingAssociations.Count);
// Identifica le associazioni i cui KeyValue non sono più presenti nella sorgente
var deletedAssociations = existingAssociations
.Where(ka => !sourceKeyValues.Contains(ka.KeyValue))
.ToList();
if (!deletedAssociations.Any())
{
_logger.LogInformation("Nessun record cancellato rilevato");
return 0;
}
_logger.LogWarning("Rilevati {Count} record cancellati dalla sorgente", deletedAssociations.Count);
// Marca le associazioni come cancellate
var now = DateTime.UtcNow;
foreach (var association in deletedAssociations)
{
association.IsSourceDeleted = true;
association.DeletedAt = now;
association.UpdatedAt = now;
_logger.LogInformation("Marcata come cancellata: KeyValue={KeyValue}, DestinationId={DestinationId}",
association.KeyValue, association.DestinationId);
}
await _context.SaveChangesAsync();
return deletedAssociations.Count;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella marcatura delle associazioni cancellate");
throw;
}
}
/// <summary>
/// Ottiene tutte le associazioni marcate come cancellate dalla sorgente ma non ancora sincronizzate
/// </summary>
public async Task<List<KeyAssociation>> GetPendingDeletionsAsync(string destinationEntity, string restCredentialName)
{
try
{
var pendingDeletions = await _context.KeyAssociations
.Where(ka => ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName &&
ka.IsSourceDeleted &&
!ka.DeletionSynced &&
ka.IsActive)
.OrderBy(ka => ka.DeletedAt)
.ToListAsync();
_logger.LogInformation("Trovate {Count} cancellazioni in attesa di sincronizzazione per {Entity}",
pendingDeletions.Count, destinationEntity);
return pendingDeletions;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero delle cancellazioni in attesa");
throw;
}
}
/// <summary>
/// Marca una cancellazione come sincronizzata
/// </summary>
public async Task<bool> MarkDeletionSyncedAsync(int associationId)
{
try
{
var association = await _context.KeyAssociations.FindAsync(associationId);
if (association == null)
{
_logger.LogWarning("Associazione {Id} non trovata per marcatura sincronizzazione", associationId);
return false;
}
association.DeletionSynced = true;
association.DeletionSyncedAt = DateTime.UtcNow;
association.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Cancellazione sincronizzata per associazione {Id} - KeyValue={KeyValue}, DestinationId={DestinationId}",
associationId, association.KeyValue, association.DestinationId);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella marcatura della sincronizzazione per associazione {Id}", associationId);
throw;
}
}
/// <summary>
/// Ottiene tutte le associazioni marcate come cancellate
/// </summary>
public async Task<List<KeyAssociation>> GetDeletedAssociationsAsync(string destinationEntity, string restCredentialName)
{
try
{
return await _context.KeyAssociations
.Where(ka => ka.DestinationEntity == destinationEntity &&
ka.RestCredentialName == restCredentialName &&
ka.IsSourceDeleted)
.OrderByDescending(ka => ka.DeletedAt)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero delle associazioni cancellate");
throw;
}
}
}