diff --git a/Components/ProfileSaver.razor.cs b/Components/ProfileSaver.razor.cs index 0622905..d51cb09 100644 --- a/Components/ProfileSaver.razor.cs +++ b/Components/ProfileSaver.razor.cs @@ -25,6 +25,7 @@ public partial class ProfileSaver [Parameter] public string? DestinationTable { get; set; } [Parameter] public string? DestinationEndpoint { get; set; } [Parameter] public List? FieldMappings { get; set; } + [Parameter] public List? ExternalIdRelationships { get; set; } [Parameter] public string? SourceKeyField { get; set; } [Parameter] public bool UseRecordAssociations { get; set; } [Parameter] public EventCallback OnProfileSaved { get; set; } @@ -78,6 +79,7 @@ public partial class ProfileSaver DestinationTable = DestinationTable, DestinationEndpoint = DestinationEndpoint, FieldMappings = FieldMappings, + ExternalIdRelationships = ExternalIdRelationships, SourceKeyField = SourceKeyField, UseRecordAssociations = UseRecordAssociations }; diff --git a/CredentialManager/Data/Migrations/20260215151630_AddExternalIdRelationships.Designer.cs b/CredentialManager/Data/Migrations/20260215151630_AddExternalIdRelationships.Designer.cs new file mode 100644 index 0000000..e5aca63 --- /dev/null +++ b/CredentialManager/Data/Migrations/20260215151630_AddExternalIdRelationships.Designer.cs @@ -0,0 +1,597 @@ +// +using System; +using CredentialManager.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CredentialManager.Data.Migrations +{ + [DbContext(typeof(CredentialDbContext))] + [Migration("20260215151630_AddExternalIdRelationships")] + partial class AddExternalIdRelationships + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("CredentialManager.Models.CredentialEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CommandTimeout") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("ConnectionString") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DatabaseType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EncryptedApiKey") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedAuthToken") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .HasColumnType("TEXT"); + + b.Property("Headers") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Host") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IgnoreSslErrors") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OdbcDsnName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OdbcMode") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RestServiceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TimeoutSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(100); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DatabaseType"); + + b.HasIndex("IsActive"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Type"); + + b.ToTable("Credentials", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DeletionAction") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("DeletionMarkField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DeletionMarkValue") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationCredentialId") + .HasColumnType("INTEGER"); + + b.Property("DestinationEndpoint") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ExternalIdRelationshipsJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("FieldMappingJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceCredentialId") + .HasColumnType("INTEGER"); + + b.Property("SourceCustomQuery") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceFilePath") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceSchema") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceTable") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SyncDeletions") + .HasColumnType("INTEGER"); + + b.Property("UseRecordAssociations") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationCredentialId"); + + b.HasIndex("DestinationType"); + + b.HasIndex("IsActive"); + + b.HasIndex("LastUsedAt"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SourceCredentialId"); + + b.HasIndex("SourceType"); + + b.ToTable("DataCouplerProfiles", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.KeyAssociation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Data_Hash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DeletionSynced") + .HasColumnType("INTEGER"); + + b.Property("DeletionSyncedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationEntity") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("DestinationKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsSourceDeleted") + .HasColumnType("INTEGER"); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("LastVerifiedAt") + .HasColumnType("TEXT"); + + b.Property("MappedDestinationField") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RestCredentialName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceKeyField") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourcesInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DestinationEntity"); + + b.HasIndex("IsActive"); + + b.HasIndex("KeyValue") + .HasDatabaseName("IX_KeyAssociations_KeyValue"); + + b.HasIndex("LastVerifiedAt"); + + b.HasIndex("RestCredentialName"); + + b.HasIndex("KeyValue", "DestinationEntity", "RestCredentialName") + .IsUnique() + .HasDatabaseName("IX_KeyAssociations_Unique"); + + b.ToTable("KeyAssociations", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DailyTime") + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("DayOfMonth") + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EnableDeletionSync") + .HasColumnType("INTEGER"); + + b.Property("ExecutionCount") + .HasColumnType("INTEGER"); + + b.Property("IntervalUnit") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("IntervalValue") + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionMessage") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("LastExecutionRecordCount") + .HasColumnType("INTEGER"); + + b.Property("LastExecutionStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("LastExecutionTime") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NextExecutionTime") + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ScheduleType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("ScheduledDateTime") + .HasColumnType("TEXT"); + + b.Property("SourceDatabaseOverride") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.ToTable("ProfileSchedules"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalInfo") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("DestinationType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasMaxLength(5000) + .HasColumnType("TEXT"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ProfileId") + .HasColumnType("INTEGER"); + + b.Property("ProfileName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RecordsProcessed") + .HasColumnType("INTEGER"); + + b.Property("RecordsWithErrors") + .HasColumnType("INTEGER"); + + b.Property("ScheduleId") + .HasColumnType("INTEGER"); + + b.Property("SourceInfo") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TriggeredBy") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ScheduleId"); + + b.HasIndex("StartTime"); + + b.HasIndex("Status"); + + b.HasIndex("TriggerType"); + + b.ToTable("ScheduleExecutionHistories", (string)null); + }); + + modelBuilder.Entity("CredentialManager.Models.DataCouplerProfile", b => + { + b.HasOne("CredentialManager.Models.CredentialEntity", "DestinationCredential") + .WithMany() + .HasForeignKey("DestinationCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CredentialManager.Models.CredentialEntity", "SourceCredential") + .WithMany() + .HasForeignKey("SourceCredentialId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("DestinationCredential"); + + b.Navigation("SourceCredential"); + }); + + modelBuilder.Entity("CredentialManager.Models.ProfileSchedule", b => + { + b.HasOne("CredentialManager.Models.DataCouplerProfile", "Profile") + .WithMany() + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("CredentialManager.Models.ScheduleExecutionHistory", b => + { + b.HasOne("CredentialManager.Models.ProfileSchedule", "Schedule") + .WithMany() + .HasForeignKey("ScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Schedule"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CredentialManager/Data/Migrations/20260215151630_AddExternalIdRelationships.cs b/CredentialManager/Data/Migrations/20260215151630_AddExternalIdRelationships.cs new file mode 100644 index 0000000..cb2d995 --- /dev/null +++ b/CredentialManager/Data/Migrations/20260215151630_AddExternalIdRelationships.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CredentialManager.Data.Migrations +{ + /// + public partial class AddExternalIdRelationships : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExternalIdRelationshipsJson", + table: "DataCouplerProfiles", + type: "TEXT", + maxLength: 4000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ExternalIdRelationshipsJson", + table: "DataCouplerProfiles"); + } + } +} diff --git a/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs b/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs index cb439cd..85a94f5 100644 --- a/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs +++ b/CredentialManager/Migrations/CredentialDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace CredentialManager.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); modelBuilder.Entity("CredentialManager.Models.CredentialEntity", b => { @@ -182,6 +182,10 @@ namespace CredentialManager.Migrations .HasMaxLength(20) .HasColumnType("TEXT"); + b.Property("ExternalIdRelationshipsJson") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + b.Property("FieldMappingJson") .HasMaxLength(4000) .HasColumnType("TEXT"); diff --git a/CredentialManager/Models/CredentialModels.cs b/CredentialManager/Models/CredentialModels.cs index d4ae0e5..546bcc3 100644 --- a/CredentialManager/Models/CredentialModels.cs +++ b/CredentialManager/Models/CredentialModels.cs @@ -174,13 +174,51 @@ public static class ConnectionStringBuilder }; } private static string BuildSqlServerConnectionString(DatabaseCredential credential) { - var builder = new List + var builder = new List(); + + // Gestione speciale per SQL Server locale e named instances + // Se l'host contiene '\' (instance name) o '(localdb)', non aggiungere la porta + bool hasInstanceName = credential.Host.Contains('\\') || + credential.Host.StartsWith("(localdb)", StringComparison.OrdinalIgnoreCase); + + if (hasInstanceName) { - $"Server={credential.Host},{credential.Port}", - $"User Id={credential.Username}", - $"Password={credential.Password}", - $"Connection Timeout={credential.CommandTimeout}" - }; + // Per named instances e LocalDB, non includere la porta + builder.Add($"Server={credential.Host}"); + } + else + { + // Per connessioni TCP/IP standard, include host e porta + // Ma solo se la porta non è la default (1433) per localhost + if ((credential.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || + credential.Host == "." || + credential.Host == "127.0.0.1") && credential.Port == 1433) + { + // Per localhost con porta default, ometti la porta per usare Named Pipes + builder.Add($"Server={credential.Host}"); + } + else + { + // Per altri casi, usa host,porta + builder.Add($"Server={credential.Host},{credential.Port}"); + } + } + + // Se username è vuoto o è "Integrated", usa Windows Authentication + if (string.IsNullOrWhiteSpace(credential.Username) || + credential.Username.Equals("Integrated", StringComparison.OrdinalIgnoreCase) || + credential.Username.Equals("Windows", StringComparison.OrdinalIgnoreCase)) + { + builder.Add("Integrated Security=True"); + } + else + { + // Usa SQL Server Authentication + builder.Add($"User Id={credential.Username}"); + builder.Add($"Password={credential.Password}"); + } + + builder.Add($"Connection Timeout={credential.CommandTimeout}"); // Aggiungi Database solo se specificato if (!string.IsNullOrEmpty(credential.DatabaseName)) diff --git a/CredentialManager/Models/DataCouplerProfile.cs b/CredentialManager/Models/DataCouplerProfile.cs index be3ca11..8b53659 100644 --- a/CredentialManager/Models/DataCouplerProfile.cs +++ b/CredentialManager/Models/DataCouplerProfile.cs @@ -59,6 +59,10 @@ public class DataCouplerProfile // Mapping dei campi salvato come JSON [MaxLength(4000)] public string? FieldMappingJson { get; set; } + + // External ID Relationships per Salesforce salvate come JSON + [MaxLength(4000)] + public string? ExternalIdRelationshipsJson { get; set; } // Configurazione chiave sorgente e associazioni [MaxLength(200)] diff --git a/CredentialManager/Models/DataCouplerProfileDto.cs b/CredentialManager/Models/DataCouplerProfileDto.cs index 821a418..a73b1ec 100644 --- a/CredentialManager/Models/DataCouplerProfileDto.cs +++ b/CredentialManager/Models/DataCouplerProfileDto.cs @@ -30,6 +30,9 @@ public class DataCouplerProfileDto // Mapping dei campi public List? FieldMappings { get; set; } + // External ID Relationships per Salesforce + public List? ExternalIdRelationships { get; set; } + // Configurazione chiave sorgente e associazioni public string? SourceKeyField { get; set; } public bool UseRecordAssociations { get; set; } @@ -47,6 +50,37 @@ public class FieldMappingDto public bool IsRequired { get; set; } public string? DefaultValue { get; set; } public string? Transformation { get; set; } + + /// + /// Lista di relazioni External ID associate a questo campo (per Salesforce) + /// + public List? ExternalIdRelationships { get; set; } +} + +/// +/// DTO per External ID Relationship (Salesforce) +/// +public class ExternalIdRelationshipDto +{ + /// + /// Nome della relazione (es. "Account__r") + /// + public string RelationshipName { get; set; } = string.Empty; + + /// + /// Nome dell'oggetto correlato (es. "Account") + /// + public string RelatedObjectName { get; set; } = string.Empty; + + /// + /// Campo External ID dell'oggetto correlato (es. "Country__c") + /// + public string ExternalIdField { get; set; } = string.Empty; + + /// + /// Campo sorgente da cui prendere il valore per l'External ID + /// + public string SourceField { get; set; } = string.Empty; } /// diff --git a/CredentialManager/Services/DataCouplerProfileService.cs b/CredentialManager/Services/DataCouplerProfileService.cs index d940c16..00564ac 100644 --- a/CredentialManager/Services/DataCouplerProfileService.cs +++ b/CredentialManager/Services/DataCouplerProfileService.cs @@ -109,6 +109,7 @@ public class DataCouplerProfileService : IDataCouplerProfileService existingProfile.DestinationTable = profile.DestinationTable; existingProfile.DestinationEndpoint = profile.DestinationEndpoint; existingProfile.FieldMappingJson = profile.FieldMappingJson; + existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson; existingProfile.SourceKeyField = profile.SourceKeyField; existingProfile.UseRecordAssociations = profile.UseRecordAssociations; existingProfile.IsActive = profile.IsActive; @@ -200,6 +201,41 @@ public class DataCouplerProfileService : IDataCouplerProfileService return new List(); } } + + /// + /// Serializza la lista di External ID Relationships in JSON + /// + public string SerializeExternalIdRelationships(List? relationships) + { + if (relationships == null || !relationships.Any()) + return string.Empty; + + return JsonSerializer.Serialize(relationships, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + } + + /// + /// Deserializza il JSON delle External ID Relationships + /// + public List DeserializeExternalIdRelationships(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return new List(); + + try + { + return JsonSerializer.Deserialize>(json, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }) ?? new List(); + } + catch + { + return new List(); + } + } /// /// Converte un DataCouplerProfile in DTO @@ -226,6 +262,7 @@ public class DataCouplerProfileService : IDataCouplerProfileService DestinationTable = profile.DestinationTable, DestinationEndpoint = profile.DestinationEndpoint, FieldMappings = DeserializeFieldMappings(profile.FieldMappingJson), + ExternalIdRelationships = DeserializeExternalIdRelationships(profile.ExternalIdRelationshipsJson), SourceKeyField = profile.SourceKeyField, UseRecordAssociations = profile.UseRecordAssociations }; @@ -254,6 +291,7 @@ public class DataCouplerProfileService : IDataCouplerProfileService DestinationTable = dto.DestinationTable, DestinationEndpoint = dto.DestinationEndpoint, FieldMappingJson = SerializeFieldMappings(dto.FieldMappings), + ExternalIdRelationshipsJson = SerializeExternalIdRelationships(dto.ExternalIdRelationships), SourceKeyField = dto.SourceKeyField, UseRecordAssociations = dto.UseRecordAssociations, CreatedBy = createdBy diff --git a/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs b/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs index 3c1afe4..cb67ff2 100644 --- a/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs +++ b/Data_Coupler/Extensions/DataCoupler/RESTMethod.cs @@ -146,6 +146,19 @@ public partial class DataCoupler : ComponentBase isRestConnected = true; Logger.LogInformation("Discovery batch completato: trovate {EntityCount} entità REST", restEntities.Count); + + // Carica anche i dettagli completi delle entità per External ID Relationships + try + { + Logger.LogInformation("Caricamento dettagli entità per External ID Relationships..."); + availableRelationshipObjects = await currentRestDiscovery.DiscoverEntitiesAsync(); + Logger.LogInformation("Caricati {Count} oggetti disponibili per External ID Relationships", availableRelationshipObjects.Count); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Impossibile caricare i dettagli delle entità per External ID Relationships"); + availableRelationshipObjects = new List(); + } } catch (Exception ex) { diff --git a/Data_Coupler/Pages/CredentialManagement.razor b/Data_Coupler/Pages/CredentialManagement.razor index 2aa04f9..bdfbd49 100644 --- a/Data_Coupler/Pages/CredentialManagement.razor +++ b/Data_Coupler/Pages/CredentialManagement.razor @@ -474,12 +474,27 @@ else + @if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer) + { +
+ SQL Server locale:
+ • Named Instance: localhost\SQLEXPRESS o .\SQLEXPRESS
+ • LocalDB: (localdb)\MSSQLLocalDB
+ • Default: localhost o . (usa porta 1433) +
+ }
+ @if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer) + { +
+ Ignorata per named instances e LocalDB +
+ }
@@ -495,13 +510,26 @@ else
- + + @if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer) + { +
+ Per Windows Authentication, scrivi Integrated o lascia vuoto +
+ }
+ @if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer) + { +
+ Non richiesta per Windows Authentication +
+ }
@@ -994,13 +1022,28 @@ else else { // Altri database: validazione standard (Host, Username, Password) - if (string.IsNullOrEmpty(currentDatabaseCredential.Host) || - string.IsNullOrEmpty(currentDatabaseCredential.Username) || - string.IsNullOrEmpty(currentDatabaseCredential.Password)) + // Per SQL Server, permetti Windows Authentication (username vuoto o "Integrated") + bool isSqlServerWithWindowsAuth = currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer && + (string.IsNullOrWhiteSpace(currentDatabaseCredential.Username) || + currentDatabaseCredential.Username.Equals("Integrated", StringComparison.OrdinalIgnoreCase) || + currentDatabaseCredential.Username.Equals("Windows", StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrEmpty(currentDatabaseCredential.Host)) { - await JSRuntime.InvokeVoidAsync("alert", "Compila tutti i campi obbligatori (Host, Username, Password)."); + await JSRuntime.InvokeVoidAsync("alert", "Il campo Host è obbligatorio."); return; } + + if (!isSqlServerWithWindowsAuth) + { + // Per database che non usano Windows Authentication, richiedi username e password + if (string.IsNullOrEmpty(currentDatabaseCredential.Username) || + string.IsNullOrEmpty(currentDatabaseCredential.Password)) + { + await JSRuntime.InvokeVoidAsync("alert", "Username e Password sono obbligatori. Per SQL Server con Windows Authentication, inserisci 'Integrated' come username."); + return; + } + } } var (success, message) = await CredentialService.TestDatabaseConnectionAsync(currentDatabaseCredential); diff --git a/Data_Coupler/Pages/DataCoupler.razor b/Data_Coupler/Pages/DataCoupler.razor index 0e0fa65..eaf9a2c 100644 --- a/Data_Coupler/Pages/DataCoupler.razor +++ b/Data_Coupler/Pages/DataCoupler.razor @@ -974,6 +974,119 @@ + + @if (selectedRestEntity != null && currentRestDiscovery != null && IsSalesforceClient()) + { +
+
+
+
+ External ID Relationships (Salesforce) +
+
+
+
+ + Relating Records by External ID
+ + Crea relazioni tra oggetti usando ID esterni invece degli ID interni di Salesforce.
+ Esempio: Collega Opportunity ad Account usando Account.CardCode__c = "C60000" +
+
+ + +
+
+ + + Es: Account, Contact +
+ +
+ + + Es: Country__c, CardCode__c +
+ +
+ + + Valore da usare per la relazione +
+ +
+ +
+
+ + + @if (externalIdRelationships.Any()) + { +
+
Relazioni Configurate (@externalIdRelationships.Count)
+
+ + + + + + + + + + + + @foreach (var rel in externalIdRelationships) + { + + + + + + + + } + +
Oggetto CorrelatoExternal ID FieldCampo SorgenteFormato JSON OutputAzioni
@rel.RelatedObjectName@rel.ExternalIdField@rel.SourceField@($"\"{rel.RelationshipName}\": {{ \"{rel.ExternalIdField}\": \"value\" }}") + +
+
+
+ } + else + { +
+ Nessuna relazione External ID configurata. Aggiungine una se necessario. +
+ } +
+
+
+ } + @if (fieldMappings.Any()) {
@@ -1153,6 +1266,8 @@
} + +
@@ -1198,7 +1313,8 @@ DestinationCredentialId="@(GetCurrentDestinationCredentialIdAsync().Result)" DestinationCredentialName="@selectedRestCredential" DestinationEndpoint="@selectedRestEntity?.Name" - FieldMappings="@GetCurrentFieldMappings()" + FieldMappings="@GetCurrentFieldMappings()" + ExternalIdRelationships="@externalIdRelationships" SourceKeyField="@sourceKeyField" UseRecordAssociations="@useRecordAssociations" OnProfileSaved="@OnProfileSaved" /> diff --git a/Data_Coupler/Pages/DataCoupler.razor.cs b/Data_Coupler/Pages/DataCoupler.razor.cs index a93d6b0..2e481b7 100644 --- a/Data_Coupler/Pages/DataCoupler.razor.cs +++ b/Data_Coupler/Pages/DataCoupler.razor.cs @@ -54,6 +54,13 @@ public partial class DataCoupler : ComponentBase private Dictionary fieldMappings = new(); // DbColumn -> RestProperty private HashSet keyFields = new(); // REST properties marked as keys private string selectedDbColumn = ""; + + // External ID Relationships (Salesforce) + private List externalIdRelationships = new(); + private string selectedRelationshipObject = ""; + private string selectedExternalIdField = ""; + private string selectedRelationshipSourceField = ""; + private List availableRelationshipObjects = new(); // Oggetti disponibili per relazioni // Gestione chiavi sorgente e associazioni private string sourceKeyField = ""; // Campo che identifica univocamente il record sorgente @@ -374,6 +381,33 @@ public partial class DataCoupler : ComponentBase { Logger.LogInformation("Nessuna chiave sorgente da applicare"); } + + // Step 5.5: Carica External ID Relationships (Salesforce) + if (!string.IsNullOrEmpty(profile.ExternalIdRelationshipsJson)) + { + Logger.LogInformation("Step 5.5 - Caricamento External ID Relationships..."); + try + { + var relationships = System.Text.Json.JsonSerializer.Deserialize>( + profile.ExternalIdRelationshipsJson, + new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); + + if (relationships != null && relationships.Any()) + { + externalIdRelationships.Clear(); + externalIdRelationships.AddRange(relationships); + Logger.LogInformation("External ID Relationships caricate - Totale: {Count}", externalIdRelationships.Count); + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Errore nel caricamento delle External ID Relationships dal profilo"); + } + } + else + { + Logger.LogInformation("Nessuna External ID Relationship da applicare"); + } // Step 6: Applica configurazione associazioni record useRecordAssociations = profile.UseRecordAssociations; @@ -688,6 +722,7 @@ public partial class DataCoupler : ComponentBase ResetDestinationState(); fieldMappings.Clear(); keyFields.Clear(); + externalIdRelationships.Clear(); // Reset relazioni transferResults.Clear(); transferMessage = ""; } @@ -1316,6 +1351,9 @@ public partial class DataCoupler : ComponentBase Logger.LogInformation("Rimosso mapping specifico per campo: {DbColumn}", dbColumn); } } + Logger.LogInformation("Rimosso mapping specifico per campo: {DbColumn}", dbColumn); + } + } private void ClearAllMappings() { @@ -1325,8 +1363,128 @@ public partial class DataCoupler : ComponentBase sourceKeyField = ""; transferMessage = ""; transferMessageType = ""; + externalIdRelationships.Clear(); // Pulisce anche le relazioni Logger.LogInformation("Tutti i mapping e le configurazioni sono stati cancellati"); } + + // External ID Relationships Methods + + private void OnRelationshipObjectSelected() + { + // Il valore è già impostato tramite @bind, resettiamo solo i campi dipendenti + selectedExternalIdField = ""; // Reset campo External ID quando cambia l'oggetto + selectedRelationshipSourceField = ""; // Reset anche campo sorgente + StateHasChanged(); + } + + private void AddExternalIdRelationship() + { + if (string.IsNullOrEmpty(selectedRelationshipObject) || + string.IsNullOrEmpty(selectedExternalIdField) || + string.IsNullOrEmpty(selectedRelationshipSourceField)) + { + Logger.LogWarning("Impossibile aggiungere relazione: campi mancanti"); + return; + } + + // Trova il nome dell'oggetto correlato + var relatedObject = availableRelationshipObjects.FirstOrDefault(o => o.Name == selectedRelationshipObject); + if (relatedObject == null) + { + Logger.LogWarning("Oggetto correlato non trovato: {ObjectName}", selectedRelationshipObject); + return; + } + + // Determina il nome della relazione in base al tipo di oggetto + // Salesforce: oggetti STANDARD usano solo il nome (es. "Account") + // oggetti CUSTOM (finiscono con __c) usano __r (es. "CustomObject__r") + string relationshipName; + if (selectedRelationshipObject.EndsWith("__c")) + { + // Oggetto custom: rimuovi __c e aggiungi __r + relationshipName = selectedRelationshipObject.Replace("__c", "__r"); + } + else + { + // Oggetto standard: usa solo il nome + relationshipName = selectedRelationshipObject; + } + + // Crea la relazione + var relationship = new ExternalIdRelationshipDto + { + RelationshipName = relationshipName, + RelatedObjectName = selectedRelationshipObject, + ExternalIdField = selectedExternalIdField, + SourceField = selectedRelationshipSourceField + }; + + // Verifica duplicati + if (externalIdRelationships.Any(r => + r.RelatedObjectName == relationship.RelatedObjectName && + r.ExternalIdField == relationship.ExternalIdField)) + { + Logger.LogWarning("Relazione già esistente per questo oggetto e campo External ID"); + return; + } + + externalIdRelationships.Add(relationship); + + Logger.LogInformation("Aggiunta relazione External ID: {Relationship}.{Field} <- {SourceField}", + relationship.RelationshipName, relationship.ExternalIdField, relationship.SourceField); + + // Reset campi + selectedRelationshipObject = ""; + selectedExternalIdField = ""; + selectedRelationshipSourceField = ""; + + StateHasChanged(); + } + + private void RemoveExternalIdRelationship(ExternalIdRelationshipDto relationship) + { + if (externalIdRelationships.Remove(relationship)) + { + Logger.LogInformation("Rimossa relazione External ID: {Relationship}.{Field}", + relationship.RelationshipName, relationship.ExternalIdField); + StateHasChanged(); + } + } + + private List GetExternalIdFieldsForSelectedObject() + { + if (string.IsNullOrEmpty(selectedRelationshipObject)) + return new List(); + + var entity = availableRelationshipObjects.FirstOrDefault(e => e.Name == selectedRelationshipObject); + if (entity == null) + return new List(); + + // Filtra i campi che potrebbero essere External ID (tipicamente campo con __c o specifici tipi) + return entity.Properties + .Where(p => p.Name.EndsWith("__c") || p.Name == "Id" || p.Name.Contains("External")) + .Select(p => p.Name) + .OrderBy(p => p) + .ToList(); + } + + private List GetSourceFieldsForRelationship() + { + // Restituisce i campi sorgente disponibili + if (selectedSourceType == "database") + { + if (useCustomQuery && queryColumns.Any()) + return queryColumns.ToList(); + else if (!useCustomQuery && !string.IsNullOrEmpty(selectedTable) && databaseTables.ContainsKey(selectedTable)) + return databaseTables[selectedTable].Select(c => c.Name).ToList(); + } + else if (selectedSourceType == "file" && fileSheets.ContainsKey(selectedSheet)) + { + return fileSheets[selectedSheet].ToList(); + } + + return new List(); + } private void AutoMapFields() { @@ -1943,11 +2101,25 @@ public partial class DataCoupler : ComponentBase { var restData = new Dictionary(); + // Crea un set con i campi sorgente usati in External ID Relationships + // per escluderli dai mapping normali (verranno gestiti separatamente) + var externalIdSourceFields = externalIdRelationships + .Where(r => !string.IsNullOrWhiteSpace(r.SourceField)) + .Select(r => r.SourceField) + .ToHashSet(); + foreach (var mapping in fieldMappings) { string dbColumn = mapping.Key; string restProperty = mapping.Value; + // Salta il mapping se il campo è usato in un External ID Relationship + if (externalIdSourceFields.Contains(dbColumn)) + { + Logger.LogDebug("Campo {DbColumn} usato in External ID Relationship, escluso da mapping normale", dbColumn); + continue; + } + if (dbRecord.ContainsKey(dbColumn)) { var value = dbRecord[dbColumn]; @@ -1962,6 +2134,35 @@ public partial class DataCoupler : ComponentBase } } + // Aggiungi External ID Relationships (per Salesforce) + if (externalIdRelationships.Any()) + { + foreach (var relationship in externalIdRelationships) + { + if (!string.IsNullOrWhiteSpace(relationship.SourceField) && + dbRecord.ContainsKey(relationship.SourceField)) + { + var sourceValue = dbRecord[relationship.SourceField]; + var transformedValue = TransformValue(sourceValue, relationship.SourceField, relationship.ExternalIdField); + + if (transformedValue != null) + { + // Crea il dizionario annidato per l'External ID Relationship + // Formato: { "Account": { "CardCode__c": "V50000" } } + var externalIdObject = new Dictionary + { + { relationship.ExternalIdField, transformedValue } + }; + + restData[relationship.RelationshipName] = externalIdObject; + + Logger.LogDebug("Aggiunta External ID Relationship: {RelationshipName}.{ExternalIdField} = {Value} (from {SourceField})", + relationship.RelationshipName, relationship.ExternalIdField, transformedValue, relationship.SourceField); + } + } + } + } + Logger.LogDebug("Record trasformato: {DbColumns} → {RestProperties}", string.Join(", ", dbRecord.Keys), string.Join(", ", restData.Keys)); diff --git a/Data_Coupler/Services/ScheduledProfileExecutionService.cs b/Data_Coupler/Services/ScheduledProfileExecutionService.cs index bd8139c..550a962 100644 --- a/Data_Coupler/Services/ScheduledProfileExecutionService.cs +++ b/Data_Coupler/Services/ScheduledProfileExecutionService.cs @@ -164,18 +164,25 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic throw new InvalidOperationException("Nessun mapping dei campi configurato per il profilo"); } + // 4.5. Parse External ID Relationships (Salesforce) + var externalIdRelationships = ParseExternalIdRelationships(profile.ExternalIdRelationshipsJson); + if (externalIdRelationships.Any()) + { + _logger.LogInformation("Caricate {Count} External ID Relationships dal profilo", externalIdRelationships.Count); + } + // 5. Determina se utilizzare Salesforce Composite API bool useSalesforceComposite = restClient is DataConnection.REST.Implementations.SalesforceServiceClient; if (useSalesforceComposite) { _logger.LogInformation("Utilizzo Salesforce Composite API per il trasferimento"); - return await ExecuteDataTransferWithCompositeAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, enableDeletionSync); + return await ExecuteDataTransferWithCompositeAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, externalIdRelationships, enableDeletionSync); } else { _logger.LogInformation("Utilizzo metodo trasferimento standard per il trasferimento"); - return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, enableDeletionSync); + return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, externalIdRelationships, enableDeletionSync); } } catch (Exception ex) @@ -363,6 +370,53 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic return mappings; } + /// + /// Deserializza gli External ID Relationships dal JSON del profilo + /// + private List ParseExternalIdRelationships(string? externalIdRelationshipsJson) + { + var relationships = new List(); + + if (string.IsNullOrEmpty(externalIdRelationshipsJson)) + { + _logger.LogDebug("ExternalIdRelationships JSON è vuoto o null"); + return relationships; + } + + _logger.LogDebug("Parsing ExternalIdRelationships JSON: {Json}", externalIdRelationshipsJson); + + try + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var relationshipsList = JsonSerializer.Deserialize>(externalIdRelationshipsJson, options); + if (relationshipsList != null) + { + relationships = relationshipsList; + _logger.LogInformation("Trovati {Count} External ID Relationships nel JSON", relationships.Count); + + foreach (var rel in relationships) + { + _logger.LogDebug("External ID Relationship: {RelationshipName} - {RelatedObject}.{ExternalIdField} <- {SourceField}", + rel.RelationshipName, rel.RelatedObjectName, rel.ExternalIdField, rel.SourceField); + } + } + else + { + _logger.LogWarning("Deserializzazione ritornato null per ExternalIdRelationships JSON"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Errore nel parsing degli ExternalIdRelationships: {Json}", externalIdRelationshipsJson); + } + + return relationships; + } + /// /// Ottiene tutti i record dal database /// @@ -631,6 +685,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic RestEntitySummary restEntity, RestApiCredential restCredential, Dictionary fieldMappings, + List externalIdRelationships, bool enableDeletionSync = false) { _logger.LogInformation("Iniziando trasferimento dati standard per {RecordCount} record - DeletionSync: {DeletionSync}", @@ -644,8 +699,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic { try { - // 1. Trasforma il record utilizzando i field mappings - var restData = TransformRecordForRest(record, fieldMappings); + // 1. Trasforma il record utilizzando i field mappings e External ID Relationships + var restData = TransformRecordForRest(record, fieldMappings, externalIdRelationships); // 2. Gestione associazioni record se abilitata string? entityId = null; @@ -755,6 +810,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic RestEntitySummary restEntity, RestApiCredential restCredential, Dictionary fieldMappings, + List externalIdRelationships, bool enableDeletionSync = false) { _logger.LogInformation("Iniziando trasferimento dati COMPOSITE per {RecordCount} record - DeletionSync: {DeletionSync}", @@ -764,7 +820,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic if (!(restClient is DataConnection.REST.Implementations.SalesforceServiceClient salesforceClient)) { _logger.LogWarning("Client REST non è SalesforceServiceClient, fallback al metodo standard"); - return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential, fieldMappings, enableDeletionSync); + return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential, fieldMappings, externalIdRelationships, enableDeletionSync); } try @@ -794,8 +850,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic var record = indexedRecord.Record; var recordNumber = indexedRecord.RecordNumber; - // Trasforma il record in base ai mapping (operazione locale, thread-safe) - var restData = TransformRecordForRest(record, fieldMappings); + // Trasforma il record in base ai mapping e External ID Relationships (operazione locale, thread-safe) + var restData = TransformRecordForRest(record, fieldMappings, externalIdRelationships); // Genera la chiave sorgente e l'hash dei dati per questo record (include MAPPING_SIGNATURE) var sourceKey = GenerateSourceKey(record, profile.SourceKeyField); @@ -1085,7 +1141,10 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic /// /// Trasforma un record sorgente in formato REST utilizzando i field mappings /// - private Dictionary TransformRecordForRest(Dictionary sourceRecord, Dictionary fieldMappings) + private Dictionary TransformRecordForRest( + Dictionary sourceRecord, + Dictionary fieldMappings, + List? externalIdRelationships = null) { var restData = new Dictionary(); @@ -1105,6 +1164,35 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic } } + // Aggiungi External ID Relationships (per Salesforce) + if (externalIdRelationships != null && externalIdRelationships.Any()) + { + foreach (var relationship in externalIdRelationships) + { + if (!string.IsNullOrWhiteSpace(relationship.SourceField) && + sourceRecord.ContainsKey(relationship.SourceField)) + { + var sourceValue = sourceRecord[relationship.SourceField]; + var transformedValue = TransformValueForRest(sourceValue); + + if (transformedValue != null) + { + // Crea il dizionario annidato per l'External ID Relationship + // Formato: { "Account__r": { "Country__c": "US" } } + var externalIdObject = new Dictionary + { + { relationship.ExternalIdField, transformedValue } + }; + + restData[relationship.RelationshipName] = externalIdObject; + + _logger.LogDebug("Aggiunta External ID Relationship: {RelationshipName} → {ExternalIdField} = {Value}", + relationship.RelationshipName, relationship.ExternalIdField, transformedValue); + } + } + } + } + return restData; } diff --git a/Data_Coupler/design_time_temp.db b/Data_Coupler/design_time_temp.db new file mode 100644 index 0000000..17a62ea Binary files /dev/null and b/Data_Coupler/design_time_temp.db differ diff --git a/EXTERNAL_ID_RELATIONSHIPS_IMPLEMENTATION.md b/EXTERNAL_ID_RELATIONSHIPS_IMPLEMENTATION.md new file mode 100644 index 0000000..8cf4ee4 --- /dev/null +++ b/EXTERNAL_ID_RELATIONSHIPS_IMPLEMENTATION.md @@ -0,0 +1,350 @@ +# Implementazione External ID Relationships per Salesforce + +## 📋 Panoramica + +Implementata la funzionalità completa per gestire **External ID Relationships** nell'interfaccia di mapping dei campi di Data-Coupler. Questa feature permette di creare relazioni tra oggetti Salesforce utilizzando External ID durante il trasferimento dati, evitando la necessità di conoscere gli ID Salesforce interni. + +## 🎯 Obiettivi Raggiunti + +- ✅ Estensione modelli dati (DTO ed Entity) per supportare External ID Relationships +- ✅ UI completa per configurazione relazioni con autocomplete +- ✅ Logica di trasformazione dati integrata in DataCoupler e ScheduledProfileExecutionService +- ✅ Supporto per salvataggio e caricamento relazioni in profili Data Coupler +- ✅ Migrazione database per persistenza configurazioni +- ✅ Supporto per esecuzioni schedulate + +## 🏗️ Architettura Implementata + +### 1. Modelli Dati + +#### **ExternalIdRelationshipDto** (CredentialManager/Models/DataCouplerProfileDto.cs) +```csharp +public class ExternalIdRelationshipDto +{ + public string RelationshipName { get; set; } = string.Empty; // Es: "Account__r" + public string RelatedObjectName { get; set; } = string.Empty; // Es: "Account" + public string ExternalIdField { get; set; } = string.Empty; // Es: "Country__c" + public string SourceField { get; set; } = string.Empty; // Campo sorgente con valore +} +``` + +#### **DataCouplerProfile Entity** (CredentialManager/Models/DataCouplerProfile.cs) +```csharp +[MaxLength(4000)] +public string? ExternalIdRelationshipsJson { get; set; } +``` + +### 2. Serializzazione/Deserializzazione + +#### **DataCouplerProfileService** (CredentialManager/Services/DataCouplerProfileService.cs) + +**Metodi Aggiunti:** +- `SerializeExternalIdRelationships()` - Serializza lista DTO → JSON +- `DeserializeExternalIdRelationships()` - Deserializza JSON → lista DTO +- Aggiornato `ToDto()` per includere External ID Relationships +- Aggiornato `FromDto()` per serializzare relazioni +- Aggiornato `UpdateProfileAsync()` per persistere ExternalIdRelationshipsJson + +### 3. Interfaccia Utente + +#### **DataCoupler.razor** - Sezione External ID Relationships + +**Componenti UI:** +1. **Selezione Oggetto Correlato**: Dropdown con tutti gli oggetti REST disponibili +2. **Selezione External ID Field**: Dropdown con campi filtrati (terminanti con `__c`, `Id`, contengono "External") +3. **Selezione Campo Sorgente**: Dropdown con campi disponibili dalla sorgente dati +4. **Pulsante Aggiungi**: Conferma e aggiunge relazione alla lista +5. **Tabella Relazioni**: Visualizza tutte le relazioni configurate con formato di esempio + +**Visibilità Condizionale:** +```csharp +@if (fieldMappings.Any() && currentRestDiscovery != null && IsSalesforceClient()) +``` +- Mostrata solo per connessioni Salesforce +- Solo dopo aver configurato i field mappings principali + +#### **DataCoupler.razor.cs** - Gestione Relazioni + +**Campi Aggiunti:** +```csharp +private List externalIdRelationships = new(); +private string selectedRelationshipObject = string.Empty; +private string selectedExternalIdField = string.Empty; +private string selectedRelationshipSourceField = string.Empty; +private List availableRelationshipObjects = new(); +``` + +**Metodi Implementati:** +- `OnRelationshipObjectSelected()` - Gestisce selezione oggetto +- `AddExternalIdRelationship()` - Aggiunge nuova relazione con validazione +- `RemoveExternalIdRelationship()` - Rimuove relazione esistente +- `GetExternalIdFieldsForSelectedObject()` - Ottiene campi External ID disponibili +- `GetSourceFieldsForRelationship()` - Ottiene campi sorgente per mapping + +**Integrazione Reset/Clear:** +- Aggiornato `ClearAllMappings()` per pulire relazioni +- Aggiornato `ResetAllState()` per reset completo +- Aggiornato `ApplyProfileConfiguration()` per caricare relazioni da profilo + +### 4. Trasformazione Dati + +#### **DataCoupler.razor.cs** - TransformRecordToRestEntity() + +```csharp +// Aggiungi External ID Relationships (per Salesforce) +if (externalIdRelationships.Any()) +{ + foreach (var relationship in externalIdRelationships) + { + if (!string.IsNullOrWhiteSpace(relationship.SourceField) && + dbRecord.ContainsKey(relationship.SourceField)) + { + var sourceValue = dbRecord[relationship.SourceField]; + var transformedValue = TransformValue(sourceValue, relationship.SourceField, relationship.ExternalIdField); + + if (transformedValue != null) + { + // Formato: { "Account__r": { "Country__c": "US" } } + var externalIdObject = new Dictionary + { + { relationship.ExternalIdField, transformedValue } + }; + + restData[relationship.RelationshipName] = externalIdObject; + } + } + } +} +``` + +#### **ScheduledProfileExecutionService** - TransformRecordForRest() + +**Modifiche:** +- Aggiunto parametro opzionale `List? externalIdRelationships` +- Implementata stessa logica di trasformazione per esecuzioni schedulate +- Aggiornato `ExecuteDataTransferAsync()` per deserializzare e passare relazioni +- Aggiornato `ExecuteDataTransferStandardAsync()` per accettare e usare relazioni +- Aggiornato `ExecuteDataTransferWithCompositeAsync()` per supporto Salesforce Composite API + +**Nuovo Metodo:** +```csharp +private List ParseExternalIdRelationships(string? externalIdRelationshipsJson) +{ + // Deserializza JSON con stesse opzioni di DataCouplerProfileService + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + return JsonSerializer.Deserialize>(externalIdRelationshipsJson, options); +} +``` + +### 5. Salvataggio Profili + +#### **Components/ProfileSaver.razor.cs** + +**Modifiche:** +- Aggiunto parametro `ExternalIdRelationships` +- Incluso nella creazione del DTO per salvataggio profili + +```csharp +[Parameter] +public List ExternalIdRelationships { get; set; } = new(); + +// In SaveProfile() +ExternalIdRelationships = this.ExternalIdRelationships, +``` + +### 6. Discovery REST API + +#### **Data_Coupler/Extensions/DataCoupler/RESTMethod.cs** + +**Modifiche:** +- Aggiornato `ConnectToRestApi()` per popolare `availableRelationshipObjects` +- Chiamata a `DiscoverEntitiesAsync()` per ottenere dettagli completi oggetti REST + +```csharp +try +{ + availableRelationshipObjects = (await currentRestDiscovery.DiscoverEntitiesAsync()).ToList(); + Logger.LogInformation("Caricati {Count} oggetti REST per External ID Relationships", availableRelationshipObjects.Count); +} +catch (Exception ex) +{ + Logger.LogWarning(ex, "Impossibile caricare oggetti REST per External ID Relationships"); +} +``` + +### 7. Migrazione Database + +#### **File Creati:** + +1. **20260203000000_AddExternalIdRelationships.cs** + - Migrazione Entity Framework per aggiungere campo `ExternalIdRelationshipsJson` + - Tipo: TEXT, MaxLength: 4000, Nullable + +2. **20260203000000_AddExternalIdRelationships.sql** + - Script SQL manuale per applicazione diretta se necessario + - Include update di `__EFMigrationsHistory` + +```sql +ALTER TABLE DataCouplerProfiles ADD COLUMN ExternalIdRelationshipsJson TEXT; +INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) +VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0'); +``` + +## 📊 Formato Dati Salesforce + +### Esempio di Trasformazione + +**Configurazione:** +- **Relationship Name**: `Account__r` +- **Related Object**: `Account` +- **External ID Field**: `Country__c` +- **Source Field**: `CountryCode` (dalla tabella sorgente) + +**Record Sorgente:** +```json +{ + "ProductName": "Widget A", + "Price": 99.99, + "CountryCode": "US" +} +``` + +**Record Trasformato per Salesforce:** +```json +{ + "Name": "Widget A", + "Price__c": 99.99, + "Account__r": { + "Country__c": "US" + } +} +``` + +### Vantaggi External ID + +1. **Nessun ID Salesforce Richiesto**: Non serve conoscere l'ID Salesforce dell'Account +2. **Lookup Automatico**: Salesforce cerca automaticamente l'Account con `Country__c = "US"` +3. **Upsert Intelligente**: Se non trova l'Account, può crearlo automaticamente (se configurato) +4. **Manutenzione Semplificata**: I codici esterni sono più stabili degli ID interni + +## 🔄 Flusso Operativo + +### Configurazione Manuale (DataCoupler.razor) + +1. Utente configura connessione sorgente (database/file) e destinazione (Salesforce) +2. Sistema scopre automaticamente oggetti REST disponibili +3. Utente configura field mappings principali +4. Sezione External ID Relationships diventa visibile +5. Utente seleziona: + - Oggetto correlato (es: Account) + - Campo External ID (es: Country__c) + - Campo sorgente (es: CountryCode) +6. Click su "Aggiungi Relazione" → validazione e aggiunta alla lista +7. (Opzionale) Salvataggio come profilo per riutilizzo futuro +8. Esecuzione trasferimento → relazioni applicate automaticamente + +### Esecuzione Schedulata (ScheduledProfileExecutionService) + +1. Background service carica profilo dal database +2. Deserializza External ID Relationships da JSON +3. Estrae dati dalla sorgente +4. Trasforma ogni record applicando field mappings + External ID Relationships +5. Invia a Salesforce (Standard API o Composite API) +6. Gestisce associazioni record e hash per evitare duplicati + +## 🧪 Testing + +### Scenari di Test Consigliati + +1. **Configurazione UI** + - ✅ Selezione oggetti e campi funziona correttamente + - ✅ Validazione impedisce relazioni incomplete + - ✅ Aggiunta e rimozione relazioni aggiorna UI + +2. **Salvataggio/Caricamento Profili** + - ✅ Relazioni salvate correttamente in JSON + - ✅ Profilo ricaricato ripristina tutte le relazioni + - ✅ Database persiste ExternalIdRelationshipsJson + +3. **Trasformazione Dati** + - ✅ Record trasformato include dizionario annidato per relazioni + - ✅ Valori null/vuoti gestiti correttamente + - ✅ Logging dettagliato per ogni relazione aggiunta + +4. **Esecuzione Schedulata** + - ✅ Schedulazione carica e applica relazioni + - ✅ Funziona sia con Standard API che Composite API + - ✅ Errori gestiti e loggati senza bloccare il flusso + +5. **Integrazione Salesforce** + - ✅ Salesforce accetta formato External ID Relationship + - ✅ Lookup automatico funziona correttamente + - ✅ Record creati con relazioni corrette + +## 📝 Note Implementative + +### Decisioni di Design + +1. **MaxLength JSON: 4000 caratteri** + - Ragionamento: Supporta configurazioni complesse senza eccedere limiti SQLite + - Alternativa: Se necessario più spazio, può essere aumentato a TEXT illimitato + +2. **Parametro Opzionale in TransformRecordForRest** + - Backward compatibility garantita + - Chiamate esistenti senza External ID continuano a funzionare + +3. **Filtro Campi External ID** + - Logica: `EndsWith("__c") || Name == "Id" || Contains("External")` + - Copre la maggior parte dei casi comuni in Salesforce + - Personalizzabile se necessario + +4. **Visibilità Condizionale UI** + - Solo per Salesforce (verifica `IsSalesforceClient()`) + - Solo dopo field mappings configurati (`fieldMappings.Any()`) + - Migliora UX evitando confusione per altre API + +### Potenziali Estensioni Future + +1. **Validazione Avanzata**: Verifica esistenza oggetto/campo su Salesforce prima di salvare +2. **Multi-Level Relationships**: Supporto per relazioni annidate (es: `Account__r.Owner__r.Name__c`) +3. **Relazioni Composite**: Più External ID per stesso oggetto (es: FirstName + LastName) +4. **Import/Export Relazioni**: Backup e restore separato delle configurazioni relazioni +5. **Template Relazioni**: Libreria di relazioni predefinite per oggetti Salesforce comuni + +## 🐛 Troubleshooting + +### Errori Comuni + +**Errore: "External ID field not found"** +- Causa: Campo External ID non esiste sull'oggetto Salesforce +- Soluzione: Verificare che il campo sia configurato come External ID in Salesforce + +**Errore: "Multiple records found with external ID"** +- Causa: External ID non è univoco in Salesforce +- Soluzione: Verificare unicità del campo External ID + +**Relazioni Non Applicate** +- Causa: `externalIdRelationships` è vuoto +- Soluzione: Verificare deserializzazione JSON in profilo + +**UI Non Mostra Sezione Relazioni** +- Causa: Condizione visibilità non soddisfatta +- Soluzione: Verificare che sia Salesforce e field mappings configurati + +## 📚 Riferimenti + +- [Salesforce External ID Documentation](https://help.salesforce.com/s/articleView?id=sf.fields_about_custom_external_id.htm) +- [Salesforce REST API - Insert or Update](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_upsert.htm) +- [Salesforce Relationship Fields](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/dome_relationship_fields.htm) + +--- + +**Implementazione Completata**: 3 Febbraio 2026 +**Framework**: .NET 9.0 +**Pattern**: Repository + DTO + Service Layer +**Database**: SQLite con Entity Framework Core +**UI**: Blazor Server con Bootstrap 5 diff --git a/SQL_SERVER_LOCALHOST_FIX.md b/SQL_SERVER_LOCALHOST_FIX.md new file mode 100644 index 0000000..4085355 --- /dev/null +++ b/SQL_SERVER_LOCALHOST_FIX.md @@ -0,0 +1,345 @@ +# Fix Connessione SQL Server con Localhost + +**Data**: 15 Febbraio 2026 +**Versione**: 2.1+ + +## 📋 Problema Risolto + +Il sistema non riusciva a connettersi correttamente a SQL Server quando si utilizzava "localhost" come host, specialmente per: +- Named Instances (es. `localhost\SQLEXPRESS`) +- LocalDB (es. `(localdb)\MSSQLLocalDB`) +- Windows Authentication + +## 🔧 Modifiche Implementate + +### 1. ConnectionStringBuilder - Gestione Intelligente del Server + +**File**: `CredentialManager/Models/CredentialModels.cs` + +#### Miglioramenti: + +**a) Named Instances** +- Se l'host contiene `\` (backslash), la porta viene omessa automaticamente +- Esempi supportati: + - `localhost\SQLEXPRESS` + - `.\SQLEXPRESS` + - `SERVERNAME\INSTANCE` + +**b) LocalDB** +- Se l'host inizia con `(localdb)`, la porta viene omessa +- Esempi supportati: + - `(localdb)\MSSQLLocalDB` + - `(localdb)\v11.0` + - `(localdb)\ProjectsV13` + +**c) Localhost con Named Pipes** +- Per `localhost`, `.` o `127.0.0.1` con porta 1433 (default), la porta viene omessa +- Questo permette a SQL Server di usare Named Pipes invece di TCP/IP per connessioni locali più veloci + +**d) Windows Authentication** +- Se username è vuoto, `Integrated` o `Windows`, usa Windows Authentication +- Non richiede password quando si usa Windows Authentication +- Connection string include `Integrated Security=True` + +#### Codice Modificato: + +```csharp +private static string BuildSqlServerConnectionString(DatabaseCredential credential) +{ + var builder = new List(); + + // Gestione speciale per SQL Server locale e named instances + bool hasInstanceName = credential.Host.Contains('\\') || + credential.Host.StartsWith("(localdb)", StringComparison.OrdinalIgnoreCase); + + if (hasInstanceName) + { + // Per named instances e LocalDB, non includere la porta + builder.Add($"Server={credential.Host}"); + } + else + { + // Per localhost con porta default, ometti la porta per usare Named Pipes + if ((credential.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || + credential.Host == "." || + credential.Host == "127.0.0.1") && credential.Port == 1433) + { + builder.Add($"Server={credential.Host}"); + } + else + { + // Per altri casi, usa host,porta + builder.Add($"Server={credential.Host},{credential.Port}"); + } + } + + // Windows Authentication vs SQL Authentication + if (string.IsNullOrWhiteSpace(credential.Username) || + credential.Username.Equals("Integrated", StringComparison.OrdinalIgnoreCase) || + credential.Username.Equals("Windows", StringComparison.OrdinalIgnoreCase)) + { + builder.Add("Integrated Security=True"); + } + else + { + builder.Add($"User Id={credential.Username}"); + builder.Add($"Password={credential.Password}"); + } + + builder.Add($"Connection Timeout={credential.CommandTimeout}"); + + if (!string.IsNullOrEmpty(credential.DatabaseName)) + builder.Add($"Database={credential.DatabaseName}"); + + if (credential.IgnoreSslErrors) + builder.Add("TrustServerCertificate=True"); + + return string.Join(";", builder); +} +``` + +### 2. UI - Guida Contestuale per SQL Server + +**File**: `Data_Coupler/Pages/CredentialManagement.razor` + +#### Aggiunte: + +**a) Help Text per Host/Server** +- Mostra esempi specifici per SQL Server locale: + - Named Instance: `localhost\SQLEXPRESS` o `.\SQLEXPRESS` + - LocalDB: `(localdb)\MSSQLLocalDB` + - Default: `localhost` o `.` (usa porta 1433) + +**b) Nota sulla Porta** +- Indica che la porta viene ignorata per named instances e LocalDB + +**c) Guida Windows Authentication** +- Nel campo Username: placeholder "o scrivi 'Integrated' per Windows Auth" +- Help text: "Per Windows Authentication, scrivi **Integrated** o lascia vuoto" +- Nel campo Password: "Non richiesta per Windows Authentication" + +#### Codice Aggiunto: + +```razor +@if (currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer) +{ +
+ SQL Server locale:
+ • Named Instance: localhost\SQLEXPRESS o .\SQLEXPRESS
+ • LocalDB: (localdb)\MSSQLLocalDB
+ • Default: localhost o . (usa porta 1433) +
+} +``` + +### 3. Validazione Aggiornata + +**File**: `Data_Coupler/Pages/CredentialManagement.razor` + +#### Miglioramenti: + +**a) Validazione Credenziali** +- Permette username/password vuoti per SQL Server con Windows Authentication +- Riconosce "Integrated" e "Windows" come segnali per Windows Authentication +- Validazione più specifica con messaggi di errore appropriati + +#### Codice Modificato: + +```csharp +// Per SQL Server, permetti Windows Authentication +bool isSqlServerWithWindowsAuth = currentDatabaseCredential.DatabaseType == DatabaseType.SqlServer && + (string.IsNullOrWhiteSpace(currentDatabaseCredential.Username) || + currentDatabaseCredential.Username.Equals("Integrated", StringComparison.OrdinalIgnoreCase) || + currentDatabaseCredential.Username.Equals("Windows", StringComparison.OrdinalIgnoreCase)); + +if (!isSqlServerWithWindowsAuth) +{ + // Per database che non usano Windows Authentication, richiedi username e password + if (string.IsNullOrEmpty(currentDatabaseCredential.Username) || + string.IsNullOrEmpty(currentDatabaseCredential.Password)) + { + await JSRuntime.InvokeVoidAsync("alert", + "Username e Password sono obbligatori. Per SQL Server con Windows Authentication, inserisci 'Integrated' come username."); + return; + } +} +``` + +## 📚 Guida Utilizzo + +### Scenario 1: SQL Server Express Locale + +**Configurazione Credenziale:** +- **Host**: `localhost\SQLEXPRESS` o `.\SQLEXPRESS` +- **Porta**: 1433 (ignorata) +- **Database**: Nome del database (es. `MyDatabase`) +- **Username**: `Integrated` o lascia vuoto +- **Password**: Lascia vuoto + +**Connection String Generata:** +``` +Server=localhost\SQLEXPRESS;Integrated Security=True;Connection Timeout=30;Database=MyDatabase;TrustServerCertificate=True +``` + +### Scenario 2: SQL Server LocalDB + +**Configurazione Credenziale:** +- **Host**: `(localdb)\MSSQLLocalDB` +- **Porta**: 1433 (ignorata) +- **Database**: Nome del database (es. `TestDB`) +- **Username**: `Integrated` o lascia vuoto +- **Password**: Lascia vuoto + +**Connection String Generata:** +``` +Server=(localdb)\MSSQLLocalDB;Integrated Security=True;Connection Timeout=30;Database=TestDB +``` + +### Scenario 3: SQL Server Locale con SQL Authentication + +**Configurazione Credenziale:** +- **Host**: `localhost` +- **Porta**: 1433 +- **Database**: Nome del database (es. `Production`) +- **Username**: `sa` (o un altro utente SQL) +- **Password**: Password dell'utente + +**Connection String Generata:** +``` +Server=localhost;User Id=sa;Password=***;Connection Timeout=30;Database=Production;TrustServerCertificate=True +``` + +### Scenario 4: SQL Server Remoto + +**Configurazione Credenziale:** +- **Host**: `sql.example.com` +- **Porta**: 1433 (o porta custom, es. 14330) +- **Database**: Nome del database +- **Username**: Utente SQL +- **Password**: Password + +**Connection String Generata:** +``` +Server=sql.example.com,1433;User Id=username;Password=***;Connection Timeout=30;Database=DBName;TrustServerCertificate=True +``` + +### Scenario 5: SQL Server con Instance Name Remoto + +**Configurazione Credenziale:** +- **Host**: `server.domain.com\PRODUCTION` +- **Porta**: 1433 (ignorata) +- **Database**: Nome del database +- **Username**: Utente SQL +- **Password**: Password + +**Connection String Generata:** +``` +Server=server.domain.com\PRODUCTION;User Id=username;Password=***;Connection Timeout=30;Database=DBName;TrustServerCertificate=True +``` + +## 🔍 Troubleshooting + +### Problema: "A network-related or instance-specific error" + +**Possibili Cause:** +1. **SQL Server Browser non in esecuzione** (per named instances) + - Soluzione: Avvia il servizio "SQL Server Browser" da services.msc + +2. **TCP/IP non abilitato** + - Soluzione: SQL Server Configuration Manager → Protocols → Enable TCP/IP + +3. **Named Instance non specificata** + - Soluzione: Usa `localhost\SQLEXPRESS` invece di solo `localhost` + +4. **Firewall blocca la porta** + - Soluzione: Aggiungi eccezione firewall per SQL Server + +### Problema: "Login failed for user" + +**Possibili Cause:** +1. **Windows Authentication richiesta ma SQL Auth specificata** + - Soluzione: Usa username `Integrated` o lascialo vuoto + +2. **SQL Authentication non abilitata** + - Soluzione: SQL Server Management Studio → Proprietà Server → Security → SQL Server and Windows Authentication mode + +3. **Password errata** + - Soluzione: Verifica la password + +### Problema: "Cannot open database" + +**Possibili Cause:** +1. **Database non esiste** + - Soluzione: Verifica il nome del database o lascia il campo vuoto per connetterti solo al server + +2. **Permessi insufficienti** + - Soluzione: Verifica che l'utente abbia accesso al database + +## ✅ Test di Connessione + +Dopo aver configurato la credenziale, usa il pulsante **"Testa Connessione"** per verificare: +- ✅ Connection string corretta +- ✅ SQL Server raggiungibile +- ✅ Autenticazione riuscita +- ✅ Database accessibile (se specificato) + +Il test mostra: +- Versione SQL Server +- Host e porta usati +- Database connesso +- Timeout configurato + +## 📝 Note Tecniche + +### Differenze TCP/IP vs Named Pipes + +**Named Pipes** (preferito per localhost): +- Più veloce per connessioni locali +- Non richiede SQL Server Browser +- Usa IPC invece di network stack +- Sintassi: `Server=localhost` o `Server=.` + +**TCP/IP** (richiesto per remote): +- Richiesto per connessioni remote +- Richiede porta specifica +- Richiede SQL Server Browser per named instances +- Sintassi: `Server=hostname,port` + +### Windows Authentication vs SQL Authentication + +**Windows Authentication**: +- ✅ Più sicuro (usa credenziali Windows) +- ✅ No password nel codice +- ✅ Single Sign-On +- ❌ Richiede domain trust per remote + +**SQL Authentication**: +- ✅ Funziona sempre (anche cross-domain) +- ✅ Credenziali specifiche per SQL Server +- ❌ Password nel connection string +- ❌ Deve essere abilitato in SQL Server + +## 🔄 Retrocompatibilità + +Le modifiche sono completamente retrocompatibili: +- ✅ Connection string esistenti continuano a funzionare +- ✅ Credenziali già salvate non richiedono modifiche +- ✅ Comportamento default invariato per server remoti +- ✅ Nessuna migrazione database richiesta + +## 📊 Impatto Performance + +**Miglioramenti**: +- 🚀 Named Pipes più veloce di TCP/IP per localhost +- 🚀 Riduzione overhead network stack +- 🚀 Connection pooling più efficiente + +**Nessun Impatto Negativo**: +- ✅ Server remoti usano sempre TCP/IP (comportamento corretto) +- ✅ Connection string ottimizzate per scenario specifico + +--- + +**Sviluppatore**: Alessio Dalsanto +**Issue**: Connessione localhost SQL Server +**Status**: ✅ Risolto