feat: Implementa gestione intelligente della chiave sorgente con rilevamento PK

- Aggiunge rilevamento automatico Primary Key per connessioni database
- Rimuove completamente il fallback automatico per lato sorgente
- Implementa selezione manuale obbligatoria per file e sorgenti non-DB
- Migliora UI con suggerimenti intelligenti e feedback visivo
- Aggiunge validazione multi-livello (UI, pre-transfer, runtime)
- Introduce metodo GetPrimaryKeyFieldAsync in IDatabaseManager
- Modifica GenerateSourceKey per richiedere sempre campo specifico
- Implementa controllo IsTransferButtonEnabled per validazione form

Breaking changes:
- La generazione automatica delle chiavi sorgente è stata rimossa
- Il campo chiave sorgente è ora obbligatorio quando si usa il sistema associazioni

Fixes: Risolve problema di discovery schema vuoto con selezione database
This commit is contained in:
2025-06-28 02:05:59 +02:00
parent 207d6fc845
commit 51c61eabf7
29 changed files with 2748 additions and 104 deletions
@@ -9,6 +9,7 @@ namespace CredentialManager.Data;
public class CredentialDbContext : DbContext
{
public DbSet<CredentialEntity> Credentials { get; set; }
public DbSet<RecordAssociation> RecordAssociations { get; set; }
public CredentialDbContext(DbContextOptions<CredentialDbContext> options) : base(options)
{
@@ -84,5 +85,55 @@ public class CredentialDbContext : DbContext
entity.HasIndex(e => e.IsActive);
});
// Configurazione della tabella RecordAssociations
modelBuilder.Entity<RecordAssociation>(entity =>
{
entity.ToTable("RecordAssociations");
entity.HasKey(e => e.Id);
entity.Property(e => e.SourceName)
.IsRequired()
.HasMaxLength(200);
entity.Property(e => e.SourceType)
.IsRequired()
.HasMaxLength(50);
entity.Property(e => e.SourceKey)
.IsRequired()
.HasMaxLength(500);
entity.Property(e => e.DestinationEntity)
.IsRequired()
.HasMaxLength(200);
entity.Property(e => e.DestinationId)
.IsRequired()
.HasMaxLength(200);
entity.Property(e => e.RestCredentialName)
.IsRequired()
.HasMaxLength(100);
entity.Property(e => e.AdditionalInfo)
.HasMaxLength(2000);
// Valori di default
entity.Property(e => e.IsActive)
.HasDefaultValue(true);
// Indici
entity.HasIndex(e => new { e.SourceName, e.SourceKey, e.DestinationEntity })
.IsUnique()
.HasDatabaseName("IX_RecordAssociations_Unique");
entity.HasIndex(e => e.SourceType);
entity.HasIndex(e => e.DestinationEntity);
entity.HasIndex(e => e.RestCredentialName);
entity.HasIndex(e => e.IsActive);
entity.HasIndex(e => e.CreatedAt);
});
}
}
@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace CredentialManager.Migrations
{
/// <summary>
/// Aggiunge la tabella RecordAssociations per tracciare le associazioni tra record sorgente e destinazione
/// </summary>
public partial class AddRecordAssociations : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RecordAssociations",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
SourceName = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
SourceType = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
SourceKey = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
DestinationEntity = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
DestinationId = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
RestCredentialName = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
IsActive = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true),
AdditionalInfo = table.Column<string>(type: "TEXT", maxLength: 2000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RecordAssociations", x => x.Id);
});
// Indici per migliorare le performance
migrationBuilder.CreateIndex(
name: "IX_RecordAssociations_Unique",
table: "RecordAssociations",
columns: new[] { "SourceName", "SourceKey", "DestinationEntity" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RecordAssociations_SourceType",
table: "RecordAssociations",
column: "SourceType");
migrationBuilder.CreateIndex(
name: "IX_RecordAssociations_DestinationEntity",
table: "RecordAssociations",
column: "DestinationEntity");
migrationBuilder.CreateIndex(
name: "IX_RecordAssociations_RestCredentialName",
table: "RecordAssociations",
column: "RestCredentialName");
migrationBuilder.CreateIndex(
name: "IX_RecordAssociations_IsActive",
table: "RecordAssociations",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_RecordAssociations_CreatedAt",
table: "RecordAssociations",
column: "CreatedAt");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RecordAssociations");
}
}
}
@@ -0,0 +1,75 @@
using System.ComponentModel.DataAnnotations;
namespace CredentialManager.Models;
/// <summary>
/// Entità per memorizzare le associazioni tra record sorgente e destinazione
/// </summary>
public class RecordAssociation
{
[Key]
public int Id { get; set; }
/// <summary>
/// Nome della sorgente dati (nome tabella/file/foglio)
/// </summary>
[Required]
[MaxLength(200)]
public string SourceName { get; set; } = string.Empty;
/// <summary>
/// Tipo di sorgente (database, file)
/// </summary>
[Required]
[MaxLength(50)]
public string SourceType { get; set; } = string.Empty;
/// <summary>
/// Chiave del record sorgente (può essere un ID o una combinazione di campi)
/// </summary>
[Required]
[MaxLength(500)]
public string SourceKey { get; set; } = string.Empty;
/// <summary>
/// Nome dell'entità di destinazione
/// </summary>
[Required]
[MaxLength(200)]
public string DestinationEntity { get; set; } = string.Empty;
/// <summary>
/// ID del record di destinazione
/// </summary>
[Required]
[MaxLength(200)]
public string DestinationId { get; set; } = string.Empty;
/// <summary>
/// Nome della credenziale REST utilizzata
/// </summary>
[Required]
[MaxLength(100)]
public string RestCredentialName { get; set; } = string.Empty;
/// <summary>
/// Data e ora della creazione dell'associazione
/// </summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Data e ora dell'ultimo aggiornamento
/// </summary>
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Indica se l'associazione è ancora attiva
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// Informazioni aggiuntive in formato JSON
/// </summary>
[MaxLength(2000)]
public string? AdditionalInfo { get; set; }
}
@@ -64,15 +64,27 @@ public class DatabaseInitializer : IDatabaseInitializer
{
try
{
// Prova a fare una query semplice per verificare che la tabella esista
// Verifica che la tabella principale Credentials esista
await _context.Credentials.CountAsync();
_logger.LogInformation("Verifica tabelle completata con successo");
}
catch (Exception ex)
{
_logger.LogWarning("Tabelle mancanti, ricreazione database...");
_logger.LogInformation("Tabella Credentials verificata con successo");
// Se le tabelle non esistono, le ricreiamo
// Verifica se la tabella RecordAssociations esiste, se non esiste la crea senza ricreare tutto il database
try
{
await _context.RecordAssociations.CountAsync();
_logger.LogInformation("Tabella RecordAssociations verificata con successo");
}
catch (Exception)
{
_logger.LogInformation("Tabella RecordAssociations non trovata, creazione tramite migrazione...");
await CreateRecordAssociationsTableAsync();
}
}
catch (Exception)
{
_logger.LogWarning("Tabella Credentials mancante, ricreazione database...");
// Solo se la tabella principale non esiste, ricreiamo tutto
await _context.Database.EnsureDeletedAsync();
await _context.Database.EnsureCreatedAsync();
await SeedInitialDataAsync();
@@ -142,7 +154,7 @@ public class DatabaseInitializer : IDatabaseInitializer
{
_logger.LogInformation("Verifica e applicazione migrazioni...");
// Verifica se la colonna RestServiceType esiste usando una query diretta
// Migrazione 1: Verifica se la colonna RestServiceType esiste
try
{
await _context.Database.ExecuteSqlRawAsync(
@@ -157,6 +169,62 @@ public class DatabaseInitializer : IDatabaseInitializer
"ALTER TABLE Credentials ADD COLUMN RestServiceType TEXT");
_logger.LogInformation("Colonna RestServiceType aggiunta con successo");
}
// Migrazione 2: Verifica se la tabella RecordAssociations esiste
try
{
await _context.Database.ExecuteSqlRawAsync(
"SELECT COUNT(*) FROM RecordAssociations LIMIT 1");
_logger.LogInformation("Tabella RecordAssociations già presente");
}
catch (Microsoft.Data.Sqlite.SqliteException)
{
// La tabella non esiste, la creiamo
_logger.LogInformation("Creazione tabella RecordAssociations...");
// Crea la tabella
await _context.Database.ExecuteSqlRawAsync(@"
CREATE TABLE RecordAssociations (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
SourceName TEXT NOT NULL,
SourceType TEXT NOT NULL,
SourceKey TEXT NOT NULL,
DestinationEntity TEXT NOT NULL,
DestinationId TEXT NOT NULL,
RestCredentialName TEXT NOT NULL,
CreatedAt TEXT NOT NULL DEFAULT (datetime('now')),
UpdatedAt TEXT,
IsActive INTEGER NOT NULL DEFAULT 1,
AdditionalInfo TEXT
)");
// Crea gli indici
await _context.Database.ExecuteSqlRawAsync(@"
CREATE UNIQUE INDEX IX_RecordAssociations_Unique
ON RecordAssociations (SourceName, SourceKey, DestinationEntity)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IX_RecordAssociations_SourceType
ON RecordAssociations (SourceType)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IX_RecordAssociations_DestinationEntity
ON RecordAssociations (DestinationEntity)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IX_RecordAssociations_RestCredentialName
ON RecordAssociations (RestCredentialName)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IX_RecordAssociations_IsActive
ON RecordAssociations (IsActive)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IX_RecordAssociations_CreatedAt
ON RecordAssociations (CreatedAt)");
_logger.LogInformation("Tabella RecordAssociations creata con successo");
}
}
catch (Exception ex)
{
@@ -164,4 +232,60 @@ public class DatabaseInitializer : IDatabaseInitializer
throw;
}
}
private async Task CreateRecordAssociationsTableAsync()
{
try
{
_logger.LogInformation("Creazione tabella RecordAssociations...");
// Crea la tabella
await _context.Database.ExecuteSqlRawAsync(@"
CREATE TABLE RecordAssociations (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
SourceName TEXT NOT NULL,
SourceType TEXT NOT NULL,
SourceKey TEXT NOT NULL,
DestinationEntity TEXT NOT NULL,
DestinationId TEXT NOT NULL,
RestCredentialName TEXT NOT NULL,
CreatedAt TEXT NOT NULL DEFAULT (datetime('now')),
UpdatedAt TEXT,
IsActive INTEGER NOT NULL DEFAULT 1,
AdditionalInfo TEXT
)");
// Crea gli indici
await _context.Database.ExecuteSqlRawAsync(@"
CREATE UNIQUE INDEX IX_RecordAssociations_Unique
ON RecordAssociations (SourceName, SourceKey, DestinationEntity)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IX_RecordAssociations_SourceType
ON RecordAssociations (SourceType)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IX_RecordAssociations_DestinationEntity
ON RecordAssociations (DestinationEntity)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IX_RecordAssociations_RestCredentialName
ON RecordAssociations (RestCredentialName)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IX_RecordAssociations_IsActive
ON RecordAssociations (IsActive)");
await _context.Database.ExecuteSqlRawAsync(@"
CREATE INDEX IX_RecordAssociations_CreatedAt
ON RecordAssociations (CreatedAt)");
_logger.LogInformation("Tabella RecordAssociations creata con successo");
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella creazione della tabella RecordAssociations");
throw;
}
}
}
@@ -0,0 +1,54 @@
using CredentialManager.Models;
namespace CredentialManager.Services;
/// <summary>
/// Interfaccia per il servizio di gestione delle associazioni record
/// </summary>
public interface IRecordAssociationService
{
/// <summary>
/// Salva una nuova associazione tra record sorgente e destinazione
/// </summary>
Task<int> SaveAssociationAsync(RecordAssociation association);
/// <summary>
/// Cerca un'associazione esistente tramite chiave sorgente
/// </summary>
Task<RecordAssociation?> FindAssociationAsync(string sourceName, string sourceKey, string destinationEntity);
/// <summary>
/// Ottiene tutte le associazioni per una sorgente specifica
/// </summary>
Task<List<RecordAssociation>> GetAssociationsBySourceAsync(string sourceName, string sourceType);
/// <summary>
/// Ottiene tutte le associazioni per un'entità di destinazione specifica
/// </summary>
Task<List<RecordAssociation>> GetAssociationsByDestinationAsync(string destinationEntity, string restCredentialName);
/// <summary>
/// Ottiene tutte le associazioni attive
/// </summary>
Task<List<RecordAssociation>> GetAllActiveAssociationsAsync();
/// <summary>
/// Aggiorna un'associazione esistente
/// </summary>
Task<bool> UpdateAssociationAsync(RecordAssociation association);
/// <summary>
/// Disattiva un'associazione (soft delete)
/// </summary>
Task<bool> DeactivateAssociationAsync(int id);
/// <summary>
/// Elimina definitivamente un'associazione
/// </summary>
Task<bool> DeleteAssociationAsync(int id);
/// <summary>
/// Pulisce le associazioni obsolete (opzionale)
/// </summary>
Task<int> CleanupOldAssociationsAsync(TimeSpan olderThan);
}
@@ -0,0 +1,250 @@
using CredentialManager.Data;
using CredentialManager.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CredentialManager.Services;
/// <summary>
/// Servizio per la gestione delle associazioni tra record sorgente e destinazione
/// </summary>
public class RecordAssociationService : IRecordAssociationService
{
private readonly CredentialDbContext _context;
private readonly ILogger<RecordAssociationService> _logger;
public RecordAssociationService(
CredentialDbContext context,
ILogger<RecordAssociationService> logger)
{
_context = context;
_logger = logger;
}
public async Task<int> SaveAssociationAsync(RecordAssociation association)
{
try
{
// Controlla se esiste già un'associazione per questa combinazione
var existing = await _context.RecordAssociations
.FirstOrDefaultAsync(ra =>
ra.SourceName == association.SourceName &&
ra.SourceKey == association.SourceKey &&
ra.DestinationEntity == association.DestinationEntity &&
ra.IsActive);
if (existing != null)
{
// Aggiorna l'associazione esistente
existing.DestinationId = association.DestinationId;
existing.RestCredentialName = association.RestCredentialName;
existing.UpdatedAt = DateTime.UtcNow;
existing.AdditionalInfo = association.AdditionalInfo;
_context.RecordAssociations.Update(existing);
await _context.SaveChangesAsync();
_logger.LogInformation("Associazione aggiornata: {SourceName}/{SourceKey} -> {DestinationEntity}/{DestinationId}",
association.SourceName, association.SourceKey, association.DestinationEntity, association.DestinationId);
return existing.Id;
}
else
{
// Crea nuova associazione
_context.RecordAssociations.Add(association);
await _context.SaveChangesAsync();
_logger.LogInformation("Nuova associazione creata: {SourceName}/{SourceKey} -> {DestinationEntity}/{DestinationId}",
association.SourceName, association.SourceKey, association.DestinationEntity, association.DestinationId);
return association.Id;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel salvare l'associazione: {SourceName}/{SourceKey} -> {DestinationEntity}",
association.SourceName, association.SourceKey, association.DestinationEntity);
throw;
}
}
public async Task<RecordAssociation?> FindAssociationAsync(string sourceName, string sourceKey, string destinationEntity)
{
try
{
return await _context.RecordAssociations
.FirstOrDefaultAsync(ra =>
ra.SourceName == sourceName &&
ra.SourceKey == sourceKey &&
ra.DestinationEntity == destinationEntity &&
ra.IsActive);
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nella ricerca dell'associazione: {SourceName}/{SourceKey} -> {DestinationEntity}",
sourceName, sourceKey, destinationEntity);
throw;
}
}
public async Task<List<RecordAssociation>> GetAssociationsBySourceAsync(string sourceName, string sourceType)
{
try
{
return await _context.RecordAssociations
.Where(ra => ra.SourceName == sourceName && ra.SourceType == sourceType && ra.IsActive)
.OrderByDescending(ra => ra.CreatedAt)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero delle associazioni per sorgente: {SourceName} ({SourceType})",
sourceName, sourceType);
throw;
}
}
public async Task<List<RecordAssociation>> GetAssociationsByDestinationAsync(string destinationEntity, string restCredentialName)
{
try
{
return await _context.RecordAssociations
.Where(ra => ra.DestinationEntity == destinationEntity &&
ra.RestCredentialName == restCredentialName &&
ra.IsActive)
.OrderByDescending(ra => ra.CreatedAt)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero delle associazioni per destinazione: {DestinationEntity} ({RestCredentialName})",
destinationEntity, restCredentialName);
throw;
}
}
public async Task<List<RecordAssociation>> GetAllActiveAssociationsAsync()
{
try
{
return await _context.RecordAssociations
.Where(ra => ra.IsActive)
.OrderByDescending(ra => ra.CreatedAt)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel recupero di tutte le associazioni attive");
throw;
}
}
public async Task<bool> UpdateAssociationAsync(RecordAssociation association)
{
try
{
var existing = await _context.RecordAssociations.FindAsync(association.Id);
if (existing == null)
{
_logger.LogWarning("Associazione con ID {Id} non trovata per l'aggiornamento", association.Id);
return false;
}
existing.DestinationId = association.DestinationId;
existing.RestCredentialName = association.RestCredentialName;
existing.UpdatedAt = DateTime.UtcNow;
existing.AdditionalInfo = association.AdditionalInfo;
existing.IsActive = association.IsActive;
_context.RecordAssociations.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.RecordAssociations.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.RecordAssociations.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.RecordAssociations.FindAsync(id);
if (association == null)
{
_logger.LogWarning("Associazione con ID {Id} non trovata per l'eliminazione", id);
return false;
}
_context.RecordAssociations.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.RecordAssociations
.Where(ra => ra.CreatedAt < cutoffDate && !ra.IsActive)
.ToListAsync();
if (oldAssociations.Any())
{
_context.RecordAssociations.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;
}
}
}