[Feature] Salesforce: batch describe metadati, discovery parallela e fix scheduler External ID
Build and Push Docker Images / Build Linux Container (push) Successful in 6m56s
Build and Push Docker Images / Build Windows Container (push) Has been cancelled
Build and Push Docker Images / Create Multi-Platform Manifest (push) Has been cancelled

- Salesforce Composite Batch API per describe SObject: le describe sono ora
  raggruppate in chunk da 25 e inviate come singole POST a /composite/batch,
  riducendo le chiamate API da N a ceil(N/25); per 200 SObject: da 201 a 9 chiamate.

- Discovery entita' REST in parallelo: DiscoverEntitySummariesAsync e
  DiscoverEntitiesAsync avviate simultaneamente; la lista entita' diventa
  interattiva subito dopo le summaries, i dettagli completano in background
  con StateHasChanged() per aggiornare l'UI istantaneamente.

- Fix scheduler - preservazione ExternalIdRelationshipsJson e DefaultValuesJson:
  in DataCoupler.razor.cs entrambi i blocchi di update profilo esistente
  (riattivazione profilo inattivo e sovrascrittura profilo attivo) omettevano
  questi campi nella copia, causandone l'azzeramento silenzioso ad ogni
  re-salvataggio. Ora entrambi i percorsi propagano correttamente i campi JSON.

- Fix scheduler - esclusione campi sorgente External ID dal mapping normale:
  in ScheduledProfileExecutionService.TransformRecordForRest i campi sorgente
  usati nelle External ID Relationships venivano inclusi anche nel loop di
  field mapping standard, generando dati duplicati nell'entita' destinazione.
  Ora il comportamento e' allineato alla UI manuale (TransformRecordToRestEntity).

- Aggiornata documentazione: README.md, AGENTS.md, copilot-instructions.md
This commit was merged in pull request #11.
This commit is contained in:
Alessio Dal Santo
2026-02-20 14:59:13 +01:00
parent b1f83aa7ab
commit 335d587c89
9 changed files with 518 additions and 108 deletions
+9 -2
View File
@@ -107,8 +107,11 @@
- **Parallel Processing**: Elaborazione parallela batch multipli - **Parallel Processing**: Elaborazione parallela batch multipli
- **Performance**: 10-25x più veloce per grandi dataset - **Performance**: 10-25x più veloce per grandi dataset
- **Riduzione API Calls**: 60-90% in meno chiamate - **Riduzione API Calls**: 60-90% in meno chiamate
- **Batch Describe Metadata**: `BatchDescribeSObjectsAsync` raggruppa le describe degli SObject in chunk da 25 (N chiamate singole → ⌈N/25⌉ richieste batch); per 200 SObject: da 201 a 9 chiamate
- **Discovery Parallela**: `DiscoverEntitySummariesAsync` e `DiscoverEntitiesAsync` eseguite in parallelo; UI interattiva dopo le summaries, dettagli completano in background
#### Metodi Batch Implementati: #### Metodi Batch Implementati:
- `BatchDescribeSObjectsAsync`: Describe batch SObject tramite Composite API (max 25 per request) — discovery metadati ottimizzata
- `BatchExecuteQueriesAsync`: Esecuzione parallela multiple query SOQL - `BatchExecuteQueriesAsync`: Esecuzione parallela multiple query SOQL
- `BatchFindEntitiesByKeysAsync`: Ricerca batch entità con diverse chiavi - `BatchFindEntitiesByKeysAsync`: Ricerca batch entità con diverse chiavi
- `BatchGetEntitiesByIdsAsync`: Recupero batch tramite ID (max 200 per query) - `BatchGetEntitiesByIdsAsync`: Recupero batch tramite ID (max 200 per query)
@@ -117,6 +120,10 @@
- `ExtractLargeDatasetAsync`: Estrattore intelligente con auto-detect strategia - `ExtractLargeDatasetAsync`: Estrattore intelligente con auto-detect strategia
- `ExtractRecentlyModifiedAsync`: Sincronizzazione incrementale - `ExtractRecentlyModifiedAsync`: Sincronizzazione incrementale
#### Correzioni Scheduler (Febbraio 2026):
- **ExternalIdRelationshipsJson / DefaultValuesJson preservati**: Fix ai blocchi di update profilo esistente in `DataCoupler.razor.cs` — i campi JSON venivano ignorati nella copia e quindi azzerati; ora entrambi i path (riattivazione + sovrascrittura) li propagano correttamente
- **Esclusione campi External ID dal mapping normale**: In `ScheduledProfileExecutionService.TransformRecordForRest`, i campi sorgente usati nelle External ID Relationships vengono ora esclusi dal loop di field mapping standard (comportamento allineato alla UI manuale)
#### File Chiave: #### File Chiave:
- `Data_Coupler/Pages/DataCoupler.razor.cs` - `Data_Coupler/Pages/DataCoupler.razor.cs`
- `DataConnection/REST/Implementations/SalesforceServiceClient.cs` - `DataConnection/REST/Implementations/SalesforceServiceClient.cs`
@@ -528,8 +535,8 @@
--- ---
**Versione**: 2.1 **Versione**: 2.2
**Ultimo Aggiornamento**: 2 Febbraio 2026 **Ultimo Aggiornamento**: 20 Febbraio 2026
**Framework**: .NET 9.0 **Framework**: .NET 9.0
**Sviluppatore**: Alessio Dalsanto **Sviluppatore**: Alessio Dalsanto
**Repository**: https://github.com/AlessioDalsi/Data-Coupler **Repository**: https://github.com/AlessioDalsi/Data-Coupler
+26 -2
View File
@@ -13,6 +13,30 @@
- **Backup e Ripristino**: Sistema completo di backup/restore per configurazioni e dati - **Backup e Ripristino**: Sistema completo di backup/restore per configurazioni e dati
- **Amministrazione Avanzata**: Interfaccia unificata per gestione sistema e sicurezza - **Amministrazione Avanzata**: Interfaccia unificata per gestione sistema e sicurezza
## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Optimizations (Febbraio 2026)**
### Salesforce Batch Describe via Composite API
**Data Aggiornamento**: Febbraio 2026
La discovery dei metadati Salesforce è stata ottimizzata tramite la Composite Batch API:
#### **`BatchDescribeSObjectsAsync`** (nuovo metodo privato in `SalesforceServiceClient`)
- Raggruppa i nomi degli SObject in chunk da 25
- Ogni chunk viene inviato come singola `POST /services/data/vXX.0/composite/batch`
- I risultati vengono processati in parallelo via `Task.WhenAll`
- **Risparmio concreto**: per 200 SObject, da 201 chiamate API a sole 9
#### **Discovery Parallela in `RESTMethod.cs`**
- `DiscoverEntitySummariesAsync` (rapida, 1 chiamata) e `DiscoverEntitiesAsync` (batch) partono in parallelo
- La lista entità diventa interattiva dopo ~0.3 s; i dettagli completano in background
- `StateHasChanged()` chiamato dopo le summaries per aggiornare subito la UI
#### **Fix Scheduler: External ID Relationships e Default Values**
- **Bug 1** (`DataCoupler.razor.cs`): in entrambi i blocchi di update profilo esistente (riattivazione profilo inattivo + sovrascrittura profilo attivo), i campi `ExternalIdRelationshipsJson` e `DefaultValuesJson` venivano omessi nella copia → cancellati silenziosamente ad ogni re-salvataggio
- **Bug 2** (`ScheduledProfileExecutionService.cs`): `TransformRecordForRest` non escludeva i campi sorgente usati nelle External ID Relationships dal loop di mapping normale, causando dati duplicati nell'entità destinazione (stessa logica già presente nella UI manuale, ora allineata allo scheduler)
---
## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Batch Extraction** ## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Batch Extraction**
### Miglioramenti Significativi alle Performance REST ### Miglioramenti Significativi alle Performance REST
@@ -1151,7 +1175,7 @@ builder.Services.AddScoped<Data_Coupler.Services.IBackupService, Data_Coupler.Se
--- ---
**Versione**: 1.0 **Versione**: 1.1
**Ultimo Aggiornamento**: Settembre 2024 **Ultimo Aggiornamento**: 20 Febbraio 2026
**Framework**: .NET 9.0 **Framework**: .NET 9.0
**Sviluppatore**: Alessio Dalsanto **Sviluppatore**: Alessio Dalsanto
@@ -109,6 +109,7 @@ public class DataCouplerProfileService : IDataCouplerProfileService
existingProfile.DestinationTable = profile.DestinationTable; existingProfile.DestinationTable = profile.DestinationTable;
existingProfile.DestinationEndpoint = profile.DestinationEndpoint; existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
existingProfile.FieldMappingJson = profile.FieldMappingJson; existingProfile.FieldMappingJson = profile.FieldMappingJson;
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson; existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
existingProfile.SourceKeyField = profile.SourceKeyField; existingProfile.SourceKeyField = profile.SourceKeyField;
existingProfile.UseRecordAssociations = profile.UseRecordAssociations; existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
@@ -175,70 +175,43 @@ namespace DataConnection.REST.Implementations
var entities = new List<RestEntityInfo>(); var entities = new List<RestEntityInfo>();
try try
{ {
// First, get list of all SObjects // Step 1: get list of all SObjects (1 API call)
var sobjectsEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/"; var sobjectsEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/";
var response = await _httpClient.GetAsync(sobjectsEndpoint, cancellationToken); var response = await _httpClient.GetAsync(sobjectsEndpoint, cancellationToken);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var sobjectsResponse = await response.Content.ReadFromJsonAsync<SalesforceSObjectsResponse>(cancellationToken: cancellationToken); if (sobjectsResponse?.SObjects != null) var sobjectsResponse = await response.Content.ReadFromJsonAsync<SalesforceSObjectsResponse>(cancellationToken: cancellationToken);
if (sobjectsResponse?.SObjects != null)
{ {
// For demo purposes, limit to first 20 objects to avoid too many API calls var sObjectNames = sobjectsResponse.SObjects
var limitedSObjects = sobjectsResponse.SObjects.ToList(); .Where(s => !string.IsNullOrEmpty(s.Name))
.Select(s => s.Name!)
.ToList();
// Process SObjects in parallel for better performance Console.WriteLine($"DiscoverEntities: {sObjectNames.Count} SObjects. Using Composite Batch API ({Math.Ceiling((double)sObjectNames.Count / 25)} request(s) instead of {sObjectNames.Count}).");
var semaphore = new SemaphoreSlim(20, 20); // Limit concurrent requests to 5
var tasks = limitedSObjects.Where(sobject => !string.IsNullOrEmpty(sobject.Name)) // Step 2: batch describe all SObjects via Composite Batch API (25 per request)
.Select(async sobject => var describeResults = await BatchDescribeSObjectsAsync(sObjectNames, cancellationToken);
foreach (var sobject in sobjectsResponse.SObjects)
{
if (string.IsNullOrEmpty(sobject.Name)) continue;
if (!describeResults.TryGetValue(sobject.Name, out var describeResult) || describeResult?.Fields == null)
continue;
var entityInfo = new RestEntityInfo { Name = sobject.Name };
foreach (var field in describeResult.Fields)
{ {
await semaphore.WaitAsync(cancellationToken); if (string.IsNullOrEmpty(field.Name)) continue;
try entityInfo.Properties.Add(new RestPropertyInfo
{ {
// Get detailed field information for each SObject Name = field.Name,
var describeEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/{sobject.Name}/describe/"; Type = field.Type ?? "string",
var describeResponse = await _httpClient.GetAsync(describeEndpoint, cancellationToken); IsKey = field.Name.Equals("Id", StringComparison.OrdinalIgnoreCase)
});
if (describeResponse.IsSuccessStatusCode) }
{ entities.Add(entityInfo);
var describeResult = await describeResponse.Content.ReadFromJsonAsync<SalesforceDescribeResponse>(cancellationToken: cancellationToken); }
if (describeResult?.Fields != null)
{
var entityInfo = new RestEntityInfo
{
Name = sobject.Name
};
foreach (var field in describeResult.Fields)
{
if (string.IsNullOrEmpty(field.Name)) continue;
var propInfo = new RestPropertyInfo
{
Name = field.Name,
Type = field.Type ?? "string",
IsKey = field.Name.Equals("Id", StringComparison.OrdinalIgnoreCase)
};
entityInfo.Properties.Add(propInfo);
}
return entityInfo;
}
}
return null;
}
catch (Exception ex)
{
Console.WriteLine($"Error describing SObject {sobject.Name}: {ex.Message}");
return null;
}
finally
{
semaphore.Release();
}
});
var results = await Task.WhenAll(tasks);
entities.AddRange(results.Where(result => result != null)!);
} }
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
@@ -382,6 +355,116 @@ namespace DataConnection.REST.Implementations
return null; return null;
} }
/// <summary>
/// Describes multiple SObjects in batches using the Salesforce Composite Batch API.
/// Reduces API calls from N (one per object) to ceil(N/25) by grouping up to 25 describe
/// requests per Composite Batch call.
/// </summary>
private async Task<Dictionary<string, SalesforceDescribeResponse?>> BatchDescribeSObjectsAsync(
List<string> sObjectNames, CancellationToken cancellationToken)
{
const int maxBatchSize = 25;
var allResults = new Dictionary<string, SalesforceDescribeResponse?>(StringComparer.OrdinalIgnoreCase);
// Split into batches of 25 (Salesforce Composite Batch limit)
var batches = new List<(List<string> Names, int BatchNumber)>();
for (int i = 0; i < sObjectNames.Count; i += maxBatchSize)
{
var chunk = sObjectNames.Skip(i).Take(maxBatchSize).ToList();
batches.Add((chunk, (i / maxBatchSize) + 1));
}
Console.WriteLine($"BatchDescribeSObjects: {sObjectNames.Count} objects → {batches.Count} Composite Batch request(s)");
var batchEndpoint = $"{_instanceUrl}/services/data/v60.0/composite/batch";
// Execute all batches in parallel
var batchTasks = batches.Select(async b =>
{
Console.WriteLine($"BatchDescribeSObjects: sending batch {b.BatchNumber}/{batches.Count} ({b.Names.Count} objects)");
var batchRequest = new SalesforceBatchDescribeRequest
{
BatchRequests = b.Names.Select(name => new SalesforceBatchDescribeSubRequest
{
Method = "GET",
Url = $"/services/data/v60.0/sobjects/{name}/describe/"
}).ToList()
};
var jsonContent = new StringContent(
JsonSerializer.Serialize(batchRequest, SalesforceJsonOptions),
System.Text.Encoding.UTF8,
"application/json"
);
var batchResults = new Dictionary<string, SalesforceDescribeResponse?>(StringComparer.OrdinalIgnoreCase);
try
{
var response = await _httpClient.PostAsync(batchEndpoint, jsonContent, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var err = await response.Content.ReadAsStringAsync(cancellationToken);
Console.WriteLine($"BatchDescribeSObjects batch {b.BatchNumber} failed: {response.StatusCode} - {err}");
foreach (var name in b.Names) batchResults[name] = null;
return batchResults;
}
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
var batchResponse = JsonSerializer.Deserialize<SalesforceBatchDescribeResponse>(responseContent, SalesforceJsonOptions);
if (batchResponse?.Results != null)
{
for (int i = 0; i < b.Names.Count; i++)
{
var objectName = b.Names[i];
if (i >= batchResponse.Results.Count)
{
batchResults[objectName] = null;
continue;
}
var subResponse = batchResponse.Results[i];
if (subResponse.StatusCode >= 200 && subResponse.StatusCode < 300 && subResponse.Result.HasValue)
{
try
{
batchResults[objectName] = JsonSerializer.Deserialize<SalesforceDescribeResponse>(
subResponse.Result.Value.GetRawText(), SalesforceJsonOptions);
}
catch (JsonException ex)
{
Console.WriteLine($"BatchDescribeSObjects: failed to parse describe for {objectName}: {ex.Message}");
batchResults[objectName] = null;
}
}
else
{
Console.WriteLine($"BatchDescribeSObjects: describe for {objectName} returned status {subResponse.StatusCode}");
batchResults[objectName] = null;
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"BatchDescribeSObjects: exception in batch {b.BatchNumber}: {ex.Message}");
foreach (var name in b.Names) batchResults[name] = null;
}
return batchResults;
});
var allBatchResults = await Task.WhenAll(batchTasks);
foreach (var batchResult in allBatchResults)
foreach (var kvp in batchResult)
allResults[kvp.Key] = kvp.Value;
var successCount = allResults.Values.Count(v => v != null);
Console.WriteLine($"BatchDescribeSObjects completed: {successCount}/{sObjectNames.Count} objects described successfully.");
return allResults;
}
/// <summary> /// <summary>
/// Creates a new SObject in Salesforce. /// Creates a new SObject in Salesforce.
/// </summary> /// </summary>
@@ -1631,6 +1714,43 @@ namespace DataConnection.REST.Implementations
public string? NextRecordsUrl { get; set; } public string? NextRecordsUrl { get; set; }
} }
// ===== Composite Batch API models (for parallel describe calls) =====
private class SalesforceBatchDescribeRequest
{
[JsonPropertyName("batchRequests")]
public List<SalesforceBatchDescribeSubRequest> BatchRequests { get; set; } = new();
}
private class SalesforceBatchDescribeSubRequest
{
[JsonPropertyName("method")]
public string Method { get; set; } = string.Empty;
[JsonPropertyName("url")]
public string Url { get; set; } = string.Empty;
}
private class SalesforceBatchDescribeResponse
{
[JsonPropertyName("hasErrors")]
public bool HasErrors { get; set; }
[JsonPropertyName("results")]
public List<SalesforceBatchDescribeSubResponse> Results { get; set; } = new();
}
private class SalesforceBatchDescribeSubResponse
{
[JsonPropertyName("statusCode")]
public int StatusCode { get; set; }
[JsonPropertyName("result")]
public JsonElement? Result { get; set; }
}
// ===== Composite API models (for create/update/query operations) =====
private class SalesforceCompositeRequest private class SalesforceCompositeRequest
{ {
[JsonPropertyName("compositeRequest")] [JsonPropertyName("compositeRequest")]
@@ -140,23 +140,29 @@ public partial class DataCoupler : ComponentBase
Logger.LogInformation("Autenticazione completata con successo per il servizio REST {ServiceType}", credential.ServiceType); Logger.LogInformation("Autenticazione completata con successo per il servizio REST {ServiceType}", credential.ServiceType);
// Discovery delle entità disponibili usando il metodo batch ottimizzato // Avvia entrambe le discovery in parallelo:
Logger.LogInformation("Iniziando discovery batch delle entità REST..."); // - DiscoverEntitySummariesAsync è veloce (1 API call) → sblocca la UI subito
restEntities = await currentRestDiscovery.DiscoverEntitySummariesAsync(); // - DiscoverEntitiesAsync è pesante (batch describe) → completa in background
Logger.LogInformation("Avvio discovery parallela: entity summaries + entity details (batch)...");
var summariesTask = currentRestDiscovery.DiscoverEntitySummariesAsync();
var entitiesTask = currentRestDiscovery.DiscoverEntitiesAsync();
// Attendi le summaries (veloci) e rendi la UI interattiva immediatamente
restEntities = await summariesTask;
isRestConnected = true; isRestConnected = true;
StateHasChanged();
Logger.LogInformation("Entity summaries completate: {EntityCount} entità. UI interattiva.", restEntities.Count);
Logger.LogInformation("Discovery batch completato: trovate {EntityCount} entità REST", restEntities.Count); // Attendi i dettagli completi (già in esecuzione in parallelo)
// Carica anche i dettagli completi delle entità per External ID Relationships
try try
{ {
Logger.LogInformation("Caricamento dettagli entità per External ID Relationships..."); availableRelationshipObjects = await entitiesTask;
availableRelationshipObjects = await currentRestDiscovery.DiscoverEntitiesAsync(); Logger.LogInformation("Entity details (batch) completati: {Count} oggetti disponibili per External ID Relationships.", availableRelationshipObjects.Count);
Logger.LogInformation("Caricati {Count} oggetti disponibili per External ID Relationships", availableRelationshipObjects.Count);
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.LogWarning(ex, "Impossibile caricare i dettagli delle entità per External ID Relationships"); Logger.LogWarning(ex, "Impossibile completare il caricamento dei dettagli entità per External ID Relationships");
availableRelationshipObjects = new List<RestEntityInfo>(); availableRelationshipObjects = new List<RestEntityInfo>();
} }
} }
+72 -16
View File
@@ -441,8 +441,26 @@ public partial class DataCoupler : ComponentBase
if (relationships != null && relationships.Any()) if (relationships != null && relationships.Any())
{ {
externalIdRelationships.Clear(); externalIdRelationships.Clear();
externalIdRelationships.AddRange(relationships);
Logger.LogInformation("External ID Relationships caricate - Totale: {Count}", externalIdRelationships.Count); // Normalizza i RelationshipName in base al tipo di oggetto destinazione
bool isDestinationCustom = selectedRestEntity?.Name?.EndsWith("__c") ?? false;
foreach (var rel in relationships)
{
// Normalizza il RelationshipName
string normalizedName = NormalizeRelationshipName(rel.RelatedObjectName, isDestinationCustom);
if (normalizedName != rel.RelationshipName)
{
Logger.LogInformation("Normalizzato RelationshipName: {Old} → {New} (Destination: {Destination}, IsCustom: {IsCustom})",
rel.RelationshipName, normalizedName, selectedRestEntity?.Name, isDestinationCustom);
rel.RelationshipName = normalizedName;
}
externalIdRelationships.Add(rel);
}
Logger.LogInformation("External ID Relationships caricate e normalizzate - Totale: {Count}", externalIdRelationships.Count);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -546,6 +564,8 @@ public partial class DataCoupler : ComponentBase
existingProfile.DestinationTable = profile.DestinationTable; existingProfile.DestinationTable = profile.DestinationTable;
existingProfile.DestinationEndpoint = profile.DestinationEndpoint; existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
existingProfile.FieldMappingJson = profile.FieldMappingJson; existingProfile.FieldMappingJson = profile.FieldMappingJson;
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
existingProfile.SourceKeyField = profile.SourceKeyField; existingProfile.SourceKeyField = profile.SourceKeyField;
existingProfile.UseRecordAssociations = profile.UseRecordAssociations; existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
existingProfile.IsActive = true; existingProfile.IsActive = true;
@@ -579,6 +599,8 @@ public partial class DataCoupler : ComponentBase
existingProfile.DestinationTable = profile.DestinationTable; existingProfile.DestinationTable = profile.DestinationTable;
existingProfile.DestinationEndpoint = profile.DestinationEndpoint; existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
existingProfile.FieldMappingJson = profile.FieldMappingJson; existingProfile.FieldMappingJson = profile.FieldMappingJson;
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
existingProfile.SourceKeyField = profile.SourceKeyField; existingProfile.SourceKeyField = profile.SourceKeyField;
existingProfile.UseRecordAssociations = profile.UseRecordAssociations; existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
@@ -1550,20 +1572,13 @@ public partial class DataCoupler : ComponentBase
return; return;
} }
// Determina il nome della relazione in base al tipo di oggetto // Determina il nome della relazione usando il metodo helper
// Salesforce: oggetti STANDARD usano solo il nome (es. "Account") bool isDestinationCustom = selectedRestEntity?.Name?.EndsWith("__c") ?? false;
// oggetti CUSTOM (finiscono con __c) usano __r (es. "CustomObject__r") string relationshipName = NormalizeRelationshipName(selectedRelationshipObject, isDestinationCustom);
string relationshipName;
if (selectedRelationshipObject.EndsWith("__c")) Logger.LogDebug("Creazione relazione - Destinazione: {Destination} (Custom: {IsCustom}), Correlato: {Related}, RelationshipName: {RelationshipName}",
{ selectedRestEntity?.Name, isDestinationCustom, selectedRelationshipObject, relationshipName);
// Oggetto custom: rimuovi __c e aggiungi __r
relationshipName = selectedRelationshipObject.Replace("__c", "__r");
}
else
{
// Oggetto standard: usa solo il nome
relationshipName = selectedRelationshipObject;
}
// Crea la relazione // Crea la relazione
var relationship = new ExternalIdRelationshipDto var relationship = new ExternalIdRelationshipDto
@@ -1606,6 +1621,47 @@ public partial class DataCoupler : ComponentBase
} }
} }
/// <summary>
/// Normalizza il nome della relazione in base al tipo di oggetto destinazione.
/// Salesforce External ID Relationships:
/// - Se l'oggetto DESTINAZIONE è CUSTOM → usa sempre __r per tutte le relazioni
/// - Se l'oggetto DESTINAZIONE è STANDARD → usa __r solo per oggetti custom correlati
/// </summary>
/// <param name="relatedObjectName">Nome dell'oggetto correlato (es. "Account", "Custom_Company__c")</param>
/// <param name="isDestinationCustom">True se l'oggetto destinazione è custom</param>
/// <returns>Nome normalizzato della relazione (es. "Account__r", "Account", "Custom_Company__r")</returns>
private string NormalizeRelationshipName(string relatedObjectName, bool isDestinationCustom)
{
if (isDestinationCustom)
{
// Destinazione CUSTOM: tutte le relazioni usano __r
if (relatedObjectName.EndsWith("__c"))
{
// Oggetto correlato custom: rimuovi __c e aggiungi __r
return relatedObjectName.Replace("__c", "__r");
}
else
{
// Oggetto correlato standard: aggiungi __r
return relatedObjectName + "__r";
}
}
else
{
// Destinazione STANDARD: solo oggetti custom correlati usano __r
if (relatedObjectName.EndsWith("__c"))
{
// Oggetto correlato custom: rimuovi __c e aggiungi __r
return relatedObjectName.Replace("__c", "__r");
}
else
{
// Oggetto correlato standard: usa solo il nome
return relatedObjectName;
}
}
}
private List<string> GetExternalIdFieldsForSelectedObject() private List<string> GetExternalIdFieldsForSelectedObject()
{ {
if (string.IsNullOrEmpty(selectedRelationshipObject)) if (string.IsNullOrEmpty(selectedRelationshipObject))
@@ -171,18 +171,25 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
_logger.LogInformation("Caricate {Count} External ID Relationships dal profilo", externalIdRelationships.Count); _logger.LogInformation("Caricate {Count} External ID Relationships dal profilo", externalIdRelationships.Count);
} }
// 4.6. Parse Default Values
var defaultValues = ParseDefaultValues(profile.DefaultValuesJson);
if (defaultValues.Any())
{
_logger.LogInformation("Caricati {Count} default values dal profilo", defaultValues.Count);
}
// 5. Determina se utilizzare Salesforce Composite API // 5. Determina se utilizzare Salesforce Composite API
bool useSalesforceComposite = restClient is DataConnection.REST.Implementations.SalesforceServiceClient; bool useSalesforceComposite = restClient is DataConnection.REST.Implementations.SalesforceServiceClient;
if (useSalesforceComposite) if (useSalesforceComposite)
{ {
_logger.LogInformation("Utilizzo Salesforce Composite API per il trasferimento"); _logger.LogInformation("Utilizzo Salesforce Composite API per il trasferimento");
return await ExecuteDataTransferWithCompositeAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, externalIdRelationships, enableDeletionSync); return await ExecuteDataTransferWithCompositeAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync);
} }
else else
{ {
_logger.LogInformation("Utilizzo metodo trasferimento standard per il trasferimento"); _logger.LogInformation("Utilizzo metodo trasferimento standard per il trasferimento");
return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, externalIdRelationships, enableDeletionSync); return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -417,6 +424,53 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
return relationships; return relationships;
} }
/// <summary>
/// Parse del JSON dei default values
/// </summary>
private Dictionary<string, (object? Value, string? Type)> ParseDefaultValues(string? defaultValuesJson)
{
var defaultValues = new Dictionary<string, (object? Value, string? Type)>();
if (string.IsNullOrEmpty(defaultValuesJson))
{
_logger.LogDebug("DefaultValues JSON è vuoto o null");
return defaultValues;
}
_logger.LogDebug("Parsing DefaultValues JSON: {Json}", defaultValuesJson);
try
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var deserializedDefaults = JsonSerializer.Deserialize<Dictionary<string, DefaultValueDto>>(defaultValuesJson, options);
if (deserializedDefaults != null)
{
foreach (var entry in deserializedDefaults)
{
defaultValues[entry.Key] = (entry.Value.Value, entry.Value.Type);
_logger.LogDebug("Default value: {Field} = {Value} ({Type})",
entry.Key, entry.Value.Value, entry.Value.Type);
}
_logger.LogInformation("Trovati {Count} default values nel JSON", defaultValues.Count);
}
else
{
_logger.LogWarning("Deserializzazione ritornato null per DefaultValues JSON");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel parsing dei default values: {Json}", defaultValuesJson);
}
return defaultValues;
}
/// <summary> /// <summary>
/// Ottiene tutti i record dal database /// Ottiene tutti i record dal database
/// </summary> /// </summary>
@@ -685,6 +739,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
RestEntitySummary restEntity, RestEntitySummary restEntity,
RestApiCredential restCredential, RestApiCredential restCredential,
Dictionary<string, string> fieldMappings, Dictionary<string, string> fieldMappings,
Dictionary<string, (object? Value, string? Type)> defaultValues,
List<ExternalIdRelationshipDto> externalIdRelationships, List<ExternalIdRelationshipDto> externalIdRelationships,
bool enableDeletionSync = false) bool enableDeletionSync = false)
{ {
@@ -699,8 +754,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
{ {
try try
{ {
// 1. Trasforma il record utilizzando i field mappings e External ID Relationships // 1. Trasforma il record utilizzando i field mappings, default values e External ID Relationships
var restData = TransformRecordForRest(record, fieldMappings, externalIdRelationships); var restData = TransformRecordForRest(record, fieldMappings, defaultValues, externalIdRelationships);
// 2. Gestione associazioni record se abilitata // 2. Gestione associazioni record se abilitata
string? entityId = null; string? entityId = null;
@@ -810,6 +865,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
RestEntitySummary restEntity, RestEntitySummary restEntity,
RestApiCredential restCredential, RestApiCredential restCredential,
Dictionary<string, string> fieldMappings, Dictionary<string, string> fieldMappings,
Dictionary<string, (object? Value, string? Type)> defaultValues,
List<ExternalIdRelationshipDto> externalIdRelationships, List<ExternalIdRelationshipDto> externalIdRelationships,
bool enableDeletionSync = false) bool enableDeletionSync = false)
{ {
@@ -820,7 +876,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
if (!(restClient is DataConnection.REST.Implementations.SalesforceServiceClient salesforceClient)) if (!(restClient is DataConnection.REST.Implementations.SalesforceServiceClient salesforceClient))
{ {
_logger.LogWarning("Client REST non è SalesforceServiceClient, fallback al metodo standard"); _logger.LogWarning("Client REST non è SalesforceServiceClient, fallback al metodo standard");
return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential, fieldMappings, externalIdRelationships, enableDeletionSync); return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync);
} }
try try
@@ -851,7 +907,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
var recordNumber = indexedRecord.RecordNumber; var recordNumber = indexedRecord.RecordNumber;
// Trasforma il record in base ai mapping e External ID Relationships (operazione locale, thread-safe) // Trasforma il record in base ai mapping e External ID Relationships (operazione locale, thread-safe)
var restData = TransformRecordForRest(record, fieldMappings, externalIdRelationships); var restData = TransformRecordForRest(record, fieldMappings, defaultValues, externalIdRelationships);
// Genera la chiave sorgente e l'hash dei dati per questo record (include MAPPING_SIGNATURE) // Genera la chiave sorgente e l'hash dei dati per questo record (include MAPPING_SIGNATURE)
var sourceKey = GenerateSourceKey(record, profile.SourceKeyField); var sourceKey = GenerateSourceKey(record, profile.SourceKeyField);
@@ -1144,12 +1200,30 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
private Dictionary<string, object> TransformRecordForRest( private Dictionary<string, object> TransformRecordForRest(
Dictionary<string, object> sourceRecord, Dictionary<string, object> sourceRecord,
Dictionary<string, string> fieldMappings, Dictionary<string, string> fieldMappings,
Dictionary<string, (object? Value, string? Type)> defaultValues,
List<ExternalIdRelationshipDto>? externalIdRelationships = null) List<ExternalIdRelationshipDto>? externalIdRelationships = null)
{ {
var restData = new Dictionary<string, object>(); var restData = new Dictionary<string, object>();
// Costruisce un set dei campi sorgente usati esclusivamente come External ID Relationship:
// questi NON devono essere inviati anche come mapping normale (stessa logica della UI manuale).
var externalIdSourceFields = (externalIdRelationships != null)
? externalIdRelationships
.Where(r => !string.IsNullOrWhiteSpace(r.SourceField))
.Select(r => r.SourceField)
.ToHashSet()
: new HashSet<string>();
// 1. Applica field mappings (escludendo i campi sorgente usati per External ID Relationships)
foreach (var mapping in fieldMappings) foreach (var mapping in fieldMappings)
{ {
// Salta il campo se è usato come sorgente in un External ID Relationship
if (externalIdSourceFields.Contains(mapping.Key))
{
_logger.LogDebug("Campo sorgente '{SourceField}' usato in External ID Relationship, escluso dal mapping normale", mapping.Key);
continue;
}
if (sourceRecord.ContainsKey(mapping.Key)) if (sourceRecord.ContainsKey(mapping.Key))
{ {
var value = sourceRecord[mapping.Key]; var value = sourceRecord[mapping.Key];
@@ -1164,7 +1238,22 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
} }
} }
// Aggiungi External ID Relationships (per Salesforce) // 2. Applica default values (solo se il campo non è già stato mappato)
foreach (var defaultValue in defaultValues)
{
if (!restData.ContainsKey(defaultValue.Key))
{
var (value, type) = defaultValue.Value;
if (value != null)
{
restData[defaultValue.Key] = value;
_logger.LogDebug("Applicato default value: {Field} = {Value} ({Type})",
defaultValue.Key, value, type);
}
}
}
// 3. Aggiungi External ID Relationships (per Salesforce)
if (externalIdRelationships != null && externalIdRelationships.Any()) if (externalIdRelationships != null && externalIdRelationships.Any())
{ {
foreach (var relationship in externalIdRelationships) foreach (var relationship in externalIdRelationships)
+116 -14
View File
@@ -196,40 +196,126 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
## 📊 Formato Dati Salesforce ## 📊 Formato Dati Salesforce
### Esempio di Trasformazione ### ⚠️ REGOLA IMPORTANTE: Formato Basato sull'Oggetto DESTINAZIONE
Il formato delle External ID Relationships dipende dal **tipo dell'oggetto DESTINAZIONE** (quello che stai creando/aggiornando), **NON** dal tipo dell'oggetto correlato:
#### **Se l'Oggetto DESTINAZIONE è CUSTOM** (es. `Sales_Quote__c`, `Custom_Order__c`):
- ✅ Tutte le relazioni usano `__r`, sia per oggetti standard che custom
- **Oggetto Standard**: `"Account__r": { "External_ID__c": "value" }`
- **Oggetto Custom**: `"Custom_Company__r": { "External_ID__c": "value" }`
#### **Se l'Oggetto DESTINAZIONE è STANDARD** (es. `Opportunity`, `Contact`):
- ✅ Solo oggetti custom correlati usano `__r`
- **Oggetto Standard**: `"Account": { "External_ID__c": "value" }`
- **Oggetto Custom**: `"Custom_Company__r": { "External_ID__c": "value" }`
### Esempi Pratici
#### Esempio 1: Destinazione CUSTOM → Relazione a Oggetto STANDARD
**Scenario**: Creo un record `Sales_Quote__c` collegato ad `Account` standard
**Configurazione:** **Configurazione:**
- **Relationship Name**: `Account__r` - **Destination Object**: `Sales_Quote__c` (CUSTOM)
- **Relationship Name**: `Account__r` ⚠️ **Usa __r anche se Account è standard!**
- **Related Object**: `Account`
- **External ID Field**: `Codice_ERP__c`
- **Source Field**: `customerCode`
**Record Trasformato:**
```json
{
"Name": "Quote 2024-001",
"Quote_Code__c": "Q001",
"Account__r": {
"Codice_ERP__c": "C60000"
}
}
```
#### Esempio 2: Destinazione STANDARD → Relazione a Oggetto STANDARD
**Scenario**: Creo un record `Opportunity` collegato ad `Account`
**Configurazione:**
- **Destination Object**: `Opportunity` (STANDARD)
- **Relationship Name**: `Account` ⚠️ **NON usa __r**
- **Related Object**: `Account` - **Related Object**: `Account`
- **External ID Field**: `Country__c` - **External ID Field**: `Country__c`
- **Source Field**: `CountryCode` (dalla tabella sorgente) - **Source Field**: `CountryCode`
**Record Trasformato:**
```json
{
"Name": "New Deal 2024",
"StageName": "Prospecting",
"Account": {
"Country__c": "US"
}
}
```
#### Esempio 3: Destinazione CUSTOM → Relazione a Oggetto CUSTOM
**Configurazione:**
- **Destination Object**: `Sales_Quote__c` (CUSTOM)
- **Relationship Name**: `Custom_Territory__r`
- **Related Object**: `Custom_Territory__c`
- **External ID Field**: `Territory_Code__c`
- **Source Field**: `territoryCode`
**Record Sorgente:** **Record Sorgente:**
```json ```json
{ {
"ProductName": "Widget A", "quoteName": "Quote A",
"Price": 99.99, "territoryCode": "NORTH-WEST"
"CountryCode": "US"
} }
``` ```
**Record Trasformato per Salesforce:** **Record Trasformato:**
```json ```json
{ {
"Name": "Widget A", "Name": "Quote A",
"Price__c": 99.99, "Custom_Territory__r": {
"Account__r": { "Territory_Code__c": "NORTH-WEST"
"Country__c": "US"
} }
} }
``` ```
### Logica di Normalizzazione Automatica
Il sistema implementa il metodo `NormalizeRelationshipName()` che garantisce il formato corretto:
```csharp
private string NormalizeRelationshipName(string relatedObjectName, bool isDestinationCustom)
{
if (isDestinationCustom)
{
// Destinazione CUSTOM: tutte le relazioni usano __r
if (relatedObjectName.EndsWith("__c"))
return relatedObjectName.Replace("__c", "__r"); // Custom_Obj__c → Custom_Obj__r
else
return relatedObjectName + "__r"; // Account → Account__r
}
else
{
// Destinazione STANDARD: solo oggetti custom usano __r
if (relatedObjectName.EndsWith("__c"))
return relatedObjectName.Replace("__c", "__r"); // Custom_Obj__c → Custom_Obj__r
else
return relatedObjectName; // Account → Account (no suffix)
}
}
```
### Vantaggi External ID ### Vantaggi External ID
1. **Nessun ID Salesforce Richiesto**: Non serve conoscere l'ID Salesforce dell'Account 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"` 2. **Lookup Automatico**: Salesforce cerca automaticamente l'oggetto correlato tramite External ID
3. **Upsert Intelligente**: Se non trova l'Account, può crearlo automaticamente (se configurato) 3. **Upsert Intelligente**: Se non trova l'oggetto, può crearlo automaticamente (se configurato)
4. **Manutenzione Semplificata**: I codici esterni sono più stabili degli ID interni 4. **Manutenzione Semplificata**: I codici esterni sono più stabili degli ID interni
5. **Normalizzazione Automatica**: Il sistema corregge automaticamente i nomi quando carica profili salvati
## 🔄 Flusso Operativo ## 🔄 Flusso Operativo
@@ -307,6 +393,14 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
- Solo dopo field mappings configurati (`fieldMappings.Any()`) - Solo dopo field mappings configurati (`fieldMappings.Any()`)
- Migliora UX evitando confusione per altre API - Migliora UX evitando confusione per altre API
5. **⭐ Normalizzazione Automatica RelationshipName (FIX CRITICO - 17 Feb 2026)**
- **Problema Risolto**: Errore `"No such column 'Account' on sobject of type Sales_Quote__c"`
- **Causa**: Il formato dipende dall'oggetto DESTINAZIONE, non dall'oggetto correlato
- **Soluzione**: Metodo `NormalizeRelationshipName()` controlla tipo oggetto destinazione
- **Funzionalità**: Corregge automaticamente i RelationshipName al caricamento profili
- **Regola**: Se destinazione è custom → usa SEMPRE `__r` per tutte le relazioni
- **Benefici**: Profili esistenti vengono corretti automaticamente senza intervento manuale
### Potenziali Estensioni Future ### Potenziali Estensioni Future
1. **Validazione Avanzata**: Verifica esistenza oggetto/campo su Salesforce prima di salvare 1. **Validazione Avanzata**: Verifica esistenza oggetto/campo su Salesforce prima di salvare
@@ -319,6 +413,13 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
### Errori Comuni ### Errori Comuni
**⚠️ Errore: "No such column 'Account' on sobject of type Sales_Quote__c"**
- **Causa**: RelationshipName incorretto per oggetto destinazione custom
- **Spiegazione**: Quando l'oggetto DESTINAZIONE è custom (es. `Sales_Quote__c`), TUTTE le relazioni devono usare `__r`, anche per oggetti standard
- **Soluzione AUTOMATICA**: ✅ Il sistema ora normalizza automaticamente i nomi delle relazioni
- **Esempio**: Se destinazione è `Sales_Quote__c` e correlato è `Account` → usa `Account__r` (non `Account`)
- **Fix Manuale**: Se usi profili vecchi, il sistema correggerà automaticamente al caricamento
**Errore: "External ID field not found"** **Errore: "External ID field not found"**
- Causa: Campo External ID non esiste sull'oggetto Salesforce - Causa: Campo External ID non esiste sull'oggetto Salesforce
- Soluzione: Verificare che il campo sia configurato come External ID in Salesforce - Soluzione: Verificare che il campo sia configurato come External ID in Salesforce
@@ -343,7 +444,8 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
--- ---
**Implementazione Completata**: 3 Febbraio 2026 **Implementazione Iniziale**: 3 Febbraio 2026
**Ultimo Aggiornamento**: 17 Febbraio 2026 - ⭐ **FIX CRITICO**: Normalizzazione automatica RelationshipName
**Framework**: .NET 9.0 **Framework**: .NET 9.0
**Pattern**: Repository + DTO + Service Layer **Pattern**: Repository + DTO + Service Layer
**Database**: SQLite con Entity Framework Core **Database**: SQLite con Entity Framework Core
+5
View File
@@ -8,6 +8,11 @@ Data-Coupler è una soluzione integrata per la gestione di connessioni dati e cr
- **DataConnection**: Libreria per connessioni a database e API REST - **DataConnection**: Libreria per connessioni a database e API REST
- **Data_Coupler**: Applicazione Blazor Server per l'interfaccia utente - **Data_Coupler**: Applicazione Blazor Server per l'interfaccia utente
### 🆕 Novità Recenti (Febbraio 2026)
-**Salesforce Batch Describe via Composite API**: I metadati degli SObject vengono ora recuperati in batch (25 per chiamata) invece di N chiamate singole, riducendo drasticamente il consumo di API durante la discovery
-**Discovery REST Parallela**: `DiscoverEntitySummariesAsync` e `DiscoverEntitiesAsync` vengono eseguite in parallelo; la lista entità diventa interattiva quasi subito, i dettagli arrivano in background
-**Fix Scheduler External ID Relationships**: Corretti due bug nello schedulatore — `ExternalIdRelationshipsJson` e `DefaultValuesJson` venivano azzerati al re-salvataggio del profilo; i campi sorgente usati nelle relazioni External ID non venivano esclusi dal mapping normale
### 🆕 Novità Recenti (Gennaio 2026) ### 🆕 Novità Recenti (Gennaio 2026)
-**Schedulazione File CSV/Excel**: Supporto completo per schedulare trasferimenti da file -**Schedulazione File CSV/Excel**: Supporto completo per schedulare trasferimenti da file
-**Validazione Percorsi**: Validazione file prima del salvataggio profili -**Validazione Percorsi**: Validazione file prima del salvataggio profili