using CredentialManager.Models; using DataConnection.REST.Configuration; using DataConnection.REST.Interfaces; using DataConnection.REST.Models; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; namespace DataConnection.REST.Implementations { /// /// Client specific for Salesforce REST API. /// Handles OAuth2 authentication and metadata discovery. /// public class SalesforceServiceClient : BaseRestServiceClient, IRestMetadataDiscovery { private string? _accessToken; private string? _instanceUrl; private DateTime _tokenExpiry; private readonly ILogger _logger; /// /// Configurazione JSON per garantire la compatibilità con Salesforce API /// Utilizza sempre la cultura invariante per i numeri per evitare problemi con virgole/punti decimali /// private static readonly JsonSerializerOptions SalesforceJsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, NumberHandling = JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; public SalesforceServiceClient(HttpClient httpClient, RestServiceOptions options, ILogger? logger = null) : base(httpClient, options) { _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; } /// /// Authenticates with Salesforce using Username/Password OAuth2 flow (grant_type=password). /// public async Task AuthenticateWithPasswordAsync(string clientId, string clientSecret, string username, string password, CancellationToken cancellationToken = default) { try { var tokenEndpoint = "/services/oauth2/token"; _logger.LogInformation("Salesforce [password flow] authenticating. URL={Url}, Username={Username}", $"{_httpClient.BaseAddress}{tokenEndpoint}", username); var tokenRequest = new List> { new("grant_type", "password"), new("client_id", clientId), new("client_secret", clientSecret), new("username", username), new("password", password) }; return await SendTokenRequestAsync(tokenRequest, "password", cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "Error during Salesforce password authentication"); return false; } } /// /// Authenticates with Salesforce using Client Credentials OAuth2 flow (grant_type=client_credentials). /// Server-to-server integration — no user interaction required. /// Prerequisites: Connected App with "Enable Client Credentials Flow" enabled and an Integration User assigned. /// NOTE: Requires a My Domain URL (e.g. https://myorg.my.salesforce.com), NOT https://login.salesforce.com. /// public async Task AuthenticateWithClientCredentialsAsync(string clientId, string clientSecret, CancellationToken cancellationToken = default) { try { var tokenEndpoint = "/services/oauth2/token"; _logger.LogInformation("Salesforce [client_credentials flow] authenticating. URL={Url}", $"{_httpClient.BaseAddress}{tokenEndpoint}"); var tokenRequest = new List> { new("grant_type", "client_credentials"), new("client_id", clientId), new("client_secret", clientSecret) }; return await SendTokenRequestAsync(tokenRequest, "client_credentials", cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "Error during Salesforce client_credentials authentication"); return false; } } /// /// Sends a token request to the Salesforce OAuth2 endpoint and stores the resulting token. /// private async Task SendTokenRequestAsync(List> tokenParams, string flowName, CancellationToken cancellationToken) { try { var tokenEndpoint = "/services/oauth2/token"; var formContent = new FormUrlEncodedContent(tokenParams); var response = await _httpClient.PostAsync(tokenEndpoint, formContent, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogWarning("Salesforce authentication ({Flow}) failed: {StatusCode}. Details: {Details}", flowName, response.StatusCode, errorContent); return false; } var tokenResponse = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (tokenResponse != null && !string.IsNullOrEmpty(tokenResponse.AccessToken)) { _accessToken = tokenResponse.AccessToken; _instanceUrl = tokenResponse.InstanceUrl; _tokenExpiry = DateTime.UtcNow.AddSeconds(3600); // Salesforce doesn't always return expires_in _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); _logger.LogInformation("Salesforce authentication ({Flow}) successful. InstanceUrl={InstanceUrl}, TokenExpiry={Expiry}", flowName, _instanceUrl, _tokenExpiry.ToLocalTime()); return true; } _logger.LogWarning("Salesforce authentication ({Flow}): token response could not be parsed or access_token was empty.", flowName); return false; } catch (HttpRequestException ex) { _logger.LogError(ex, "HTTP error during Salesforce authentication ({Flow})", flowName); return false; } catch (JsonException ex) { _logger.LogError(ex, "JSON parsing error during Salesforce authentication ({Flow})", flowName); return false; } } /// /// Authenticates with Salesforce using the grant type configured in options. /// Routes to or /// based on . /// public override async Task AuthenticateAsync(CancellationToken cancellationToken = default) { var clientId = _options.ApiKey; var clientSecret = _options.AuthToken; if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret)) { _logger.LogError("Salesforce authentication requires ClientId (ApiKey) and ClientSecret (AuthToken) in options. " + "ClientId={HasClientId}, ClientSecret={HasSecret}", !string.IsNullOrEmpty(clientId), !string.IsNullOrEmpty(clientSecret)); return false; } if (_options.SalesforceGrantType == SalesforceGrantType.ClientCredentials) { return await AuthenticateWithClientCredentialsAsync(clientId, clientSecret, cancellationToken); } // Default: password flow if (string.IsNullOrEmpty(_options.Username) || string.IsNullOrEmpty(_options.Password)) { _logger.LogError("Salesforce password flow requires Username and Password in options. " + "Username={HasUsername}, Password={HasPassword}", !string.IsNullOrEmpty(_options.Username), !string.IsNullOrEmpty(_options.Password)); return false; } return await AuthenticateWithPasswordAsync(clientId, clientSecret, _options.Username, _options.Password, cancellationToken); } /// /// Checks if the current access token is active. /// public bool IsAuthenticated() { return !string.IsNullOrEmpty(_accessToken) && DateTime.UtcNow < _tokenExpiry; } /// /// Discovers SObjects (entities) and their fields from Salesforce. /// /// Cancellation token /// A list of discovered entity information public async Task> DiscoverEntitiesAsync(CancellationToken cancellationToken = default) { if (!IsAuthenticated()) { _logger.LogDebug("Error: Not authenticated to Salesforce. Cannot discover metadata."); return new List(); } var entities = new List(); try { // Step 1: get list of all SObjects (1 API call) var sobjectsEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/"; var response = await _httpClient.GetAsync(sobjectsEndpoint, cancellationToken); response.EnsureSuccessStatusCode(); var sobjectsResponse = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (sobjectsResponse?.SObjects != null) { var sObjectNames = sobjectsResponse.SObjects .Where(s => !string.IsNullOrEmpty(s.Name)) .Select(s => s.Name!) .ToList(); _logger.LogDebug($"DiscoverEntities: {sObjectNames.Count} SObjects. Using Composite Batch API ({Math.Ceiling((double)sObjectNames.Count / 25)} request(s) instead of {sObjectNames.Count})."); // Step 2: batch describe all SObjects via Composite Batch API (25 per request) 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) { if (string.IsNullOrEmpty(field.Name)) continue; entityInfo.Properties.Add(new RestPropertyInfo { Name = field.Name, Type = field.Type ?? "string", IsKey = field.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) }); } entities.Add(entityInfo); } } } catch (HttpRequestException ex) { _logger.LogDebug($"HTTP Request Error during Salesforce metadata discovery: {ex.Message}"); } catch (JsonException ex) { _logger.LogDebug($"JSON Parsing Error during Salesforce metadata discovery: {ex.Message}"); } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce metadata discovery: {ex.Message}"); } return entities; } /// /// Discovers a list of available SObjects from Salesforce without detailed field information. /// /// Cancellation token /// A list of discovered SObject summaries public async Task> DiscoverEntitySummariesAsync(CancellationToken cancellationToken = default) { if (!IsAuthenticated()) { _logger.LogDebug("Error: Not authenticated to Salesforce. Cannot discover metadata."); return new List(); } var entities = new List(); try { // Get list of all SObjects var sobjectsEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/"; var response = await _httpClient.GetAsync(sobjectsEndpoint, cancellationToken); response.EnsureSuccessStatusCode(); var sobjectsResponse = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (sobjectsResponse?.SObjects != null) { foreach (var sobject in sobjectsResponse.SObjects) { if (string.IsNullOrEmpty(sobject.Name)) continue; var entitySummary = new RestEntitySummary { Name = sobject.Name, Label = sobject.Label ?? sobject.Name, IsCustom = sobject.Custom, EntityType = "SObject" }; entities.Add(entitySummary); } } } catch (HttpRequestException ex) { _logger.LogDebug($"HTTP Request Error during Salesforce metadata discovery: {ex.Message}"); } catch (JsonException ex) { _logger.LogDebug($"JSON Parsing Error during Salesforce metadata discovery: {ex.Message}"); } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce metadata discovery: {ex.Message}"); } return entities.OrderBy(e => e.Name).ToList(); } /// /// Discovers detailed information for a specific SObject including all its fields. /// /// The name of the SObject to get details for /// Cancellation token /// Detailed SObject information or null if not found public async Task DiscoverEntityDetailsAsync(string entityName, CancellationToken cancellationToken = default) { if (!IsAuthenticated()) { _logger.LogDebug("Error: Not authenticated to Salesforce. Cannot discover entity details."); return null; } try { // Get detailed field information for the specific SObject var describeEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/{entityName}/describe/"; var describeResponse = await _httpClient.GetAsync(describeEndpoint, cancellationToken); if (!describeResponse.IsSuccessStatusCode) { _logger.LogDebug($"Failed to get details for SObject {entityName}: {describeResponse.StatusCode}"); return null; } var describeResult = await describeResponse.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); if (describeResult?.Fields != null) { var entityInfo = new RestEntityInfo { Name = entityName }; 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), IsRequired = !field.Nillable, MaxLength = field.Length > 0 ? field.Length : null }; entityInfo.Properties.Add(propInfo); } return entityInfo; } } catch (HttpRequestException ex) { _logger.LogDebug($"HTTP Request Error during Salesforce entity details discovery: {ex.Message}"); } catch (JsonException ex) { _logger.LogDebug($"JSON Parsing Error during Salesforce entity details discovery: {ex.Message}"); } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce entity details discovery: {ex.Message}"); } return null; } /// /// 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. /// private async Task> BatchDescribeSObjectsAsync( List sObjectNames, CancellationToken cancellationToken) { const int maxBatchSize = 25; var allResults = new Dictionary(StringComparer.OrdinalIgnoreCase); // Split into batches of 25 (Salesforce Composite Batch limit) var batches = new List<(List 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)); } _logger.LogDebug($"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 => { _logger.LogDebug($"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(StringComparer.OrdinalIgnoreCase); try { var response = await _httpClient.PostAsync(batchEndpoint, jsonContent, cancellationToken); if (!response.IsSuccessStatusCode) { var err = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogDebug($"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(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( subResponse.Result.Value.GetRawText(), SalesforceJsonOptions); } catch (JsonException ex) { _logger.LogDebug($"BatchDescribeSObjects: failed to parse describe for {objectName}: {ex.Message}"); batchResults[objectName] = null; } } else { _logger.LogDebug($"BatchDescribeSObjects: describe for {objectName} returned status {subResponse.StatusCode}"); batchResults[objectName] = null; } } } } catch (Exception ex) { _logger.LogDebug($"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); _logger.LogDebug($"BatchDescribeSObjects completed: {successCount}/{sObjectNames.Count} objects described successfully."); return allResults; } /// /// Creates a new SObject in Salesforce. /// /// The name of the SObject to create (e.g., "Account", "Contact"). /// The data for the new SObject as key-value pairs. /// Cancellation token. /// The created entity data or null if creation failed. public override async Task?> CreateEntityAsync(string entityName, Dictionary entityData, CancellationToken cancellationToken = default) { if (!IsAuthenticated()) { _logger.LogDebug("Error: Not authenticated to Salesforce. Cannot create entity."); return null; } // Salesforce REST API endpoint for creating SObjects var createUri = $"{_instanceUrl}/services/data/v60.0/sobjects/{entityName}/"; try { _logger.LogDebug($"--- Salesforce Entity Creation Attempt ---"); _logger.LogDebug($"SObject: {entityName}"); _logger.LogDebug($"Target URL: {createUri}"); _logger.LogDebug($"Data: {JsonSerializer.Serialize(entityData, SalesforceJsonOptions)}"); // Normalizza i valori numerici per evitare problemi con virgole decimali var normalizedData = NormalizeNumericValues(entityData); // Usa StringContent con configurazione JSON specifica per Salesforce var jsonContent = new StringContent( JsonSerializer.Serialize(normalizedData, SalesforceJsonOptions), System.Text.Encoding.UTF8, "application/json" ); var response = await _httpClient.PostAsync(createUri, jsonContent, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogDebug($"Salesforce Entity Creation failed: {response.StatusCode}"); _logger.LogDebug($"Error details: {errorContent}"); _logger.LogDebug($"--- End Salesforce Entity Creation Attempt (Failed) ---"); return null; } var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogDebug($"Salesforce Entity Creation successful"); _logger.LogDebug($"Response: {responseContent}"); _logger.LogDebug($"--- End Salesforce Entity Creation Attempt (Success) ---"); if (string.IsNullOrEmpty(responseContent)) return entityData; // Return original data if no response content // Salesforce returns creation result with Id and success status var creationResult = JsonSerializer.Deserialize>(responseContent, SalesforceJsonOptions); // Merge the original data with the creation result (which includes the new Id) if (creationResult != null) { var result = new Dictionary(entityData); foreach (var kvp in creationResult) { result[kvp.Key] = kvp.Value; } return result; } return creationResult; } catch (HttpRequestException ex) { _logger.LogDebug($"HTTP Request Error during Salesforce entity creation: {ex.Message}"); if (ex.InnerException != null) { _logger.LogDebug($"Inner Exception: {ex.InnerException.Message}"); } _logger.LogDebug($"--- End Salesforce Entity Creation Attempt (Exception) ---"); return null; } catch (JsonException ex) { _logger.LogDebug($"JSON Parsing Error during Salesforce entity creation: {ex.Message}"); _logger.LogDebug($"--- End Salesforce Entity Creation Attempt (JsonException) ---"); return null; } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce entity creation: {ex.Message}"); _logger.LogDebug($"--- End Salesforce Entity Creation Attempt (Exception) ---"); return null; } } public override async Task?> UpsertEntityAsync(string entityName, Dictionary entityData, CancellationToken cancellationToken = default) { // Per Salesforce, implementiamo upsert provando prima la creazione // Se fallisce con un errore di duplicato, potremmo implementare logic di aggiornamento // Per ora, semplicemente tentiamo la creazione try { _logger.LogDebug($"--- Starting Salesforce Entity Upsert: {entityName} ---"); _logger.LogDebug($"Entity Data: {string.Join(", ", entityData.Select(kvp => $"{kvp.Key}={kvp.Value}"))}"); // Prima tenta la creazione var result = await CreateEntityAsync(entityName, entityData, cancellationToken); if (result != null) { _logger.LogDebug($"Upsert completed successfully via CREATE for {entityName}"); return result; } // Se la creazione fallisce, potresti implementare qui la logica di aggiornamento // Per ora, restituiamo null _logger.LogDebug($"Upsert failed for {entityName}"); return null; } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce entity upsert: {ex.Message}"); return null; } } /// /// Finds entities by their key fields in Salesforce. /// Uses External ID GET when possible (single field), otherwise uses SOQL query. /// /// The name of the SObject to search (e.g., "Account", "Contact"). /// The key fields and their values to match. /// Cancellation token. /// A list of matching entities or an empty list if none found. public override async Task>> FindEntitiesByKeysAsync(string entityName, Dictionary keyFields, CancellationToken cancellationToken = default) { try { _logger.LogDebug($"--- Starting Salesforce Entity Search: {entityName} ---"); _logger.LogDebug($"Key Fields: {string.Join(", ", keyFields.Select(kvp => $"{kvp.Key}={kvp.Value}"))}"); if (!await EnsureAuthenticatedAsync(cancellationToken)) { _logger.LogDebug("Authentication failed for entity search"); return new List>(); } // 🔍 IMPORTANTE: L'approccio External ID GET funziona SOLO per campi marcati come External ID in Salesforce // Per la maggior parte dei campi, è più affidabile usare direttamente SOQL query // Se vuoi abilitare il tentativo External ID, decommenta il blocco sotto /* EXTERNAL ID GET - DISABILITATO PER DEFAULT if (keyFields.Count == 1) { var kvp = keyFields.First(); var fieldName = kvp.Key; var fieldValue = kvp.Value?.ToString() ?? ""; try { // Tentativo 1: GET con External ID (funziona SOLO per campi External ID) // Endpoint: /sobjects/{objectType}/{fieldName}/{fieldValue} var externalIdEndpoint = $"{_instanceUrl}/services/data/v60.0/sobjects/{entityName}/{fieldName}/{Uri.EscapeDataString(fieldValue)}"; _logger.LogDebug($"⚠️ Tentativo GET con External ID: {externalIdEndpoint}"); _logger.LogDebug($"⚠️ NOTA: Questo funziona SOLO se '{fieldName}' è marcato come External ID in Salesforce"); var getResponse = await _httpClient.GetAsync(externalIdEndpoint, cancellationToken); if (getResponse.IsSuccessStatusCode) { var entity = await getResponse.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken); if (entity != null) { _logger.LogDebug($"✅ Trovato tramite External ID GET: Id={entity.GetValueOrDefault("Id")}"); return new List> { entity }; } } else if (getResponse.StatusCode == System.Net.HttpStatusCode.NotFound) { _logger.LogDebug($"⚠️ External ID GET ha restituito 404 - Il campo '{fieldName}' probabilmente non è External ID"); _logger.LogDebug($" Uso SOQL query come fallback (metodo universale)"); } else { _logger.LogDebug($"External ID GET non disponibile (Status: {getResponse.StatusCode}), uso SOQL query"); } } catch (Exception externalIdEx) { _logger.LogDebug($"External ID GET fallito: {externalIdEx.Message}, uso SOQL query"); } } */ // 🔍 SOQL Query: metodo universale che funziona per TUTTI i campi (External ID o no) _logger.LogDebug("📋 Usando SOQL Query per la ricerca (metodo universale)..."); // Costruisci le condizioni WHERE var whereConditions = new List(); foreach (var kvp in keyFields) { var fieldName = kvp.Key; var fieldValue = kvp.Value; _logger.LogDebug($" 🔎 Ricerca per campo: {fieldName} = {fieldValue}"); var value = fieldValue?.ToString() ?? ""; // Se il valore è una stringa, aggiungi le virgolette ed escape if (fieldValue is string) { value = $"'{value.Replace("'", "\\'")}'"; // Escape delle virgolette } whereConditions.Add($"{fieldName} = {value}"); } // Costruisci la query: seleziona tutti i campi forniti + Id var fieldsToSelect = new List { "Id" }; fieldsToSelect.AddRange(keyFields.Keys.Where(k => k != "Id")); var query = $"SELECT {string.Join(", ", fieldsToSelect)} FROM {entityName} WHERE {string.Join(" AND ", whereConditions)}"; _logger.LogDebug($"📝 SOQL Query: {query}"); // Usa l'endpoint query con autenticazione corretta var encodedQuery = Uri.EscapeDataString(query); var queryEndpoint = $"{_instanceUrl}/services/data/v60.0/query/?q={encodedQuery}"; _logger.LogDebug($"Query Endpoint: {queryEndpoint}"); // Usa GET con autenticazione inclusa nell'HttpClient (già configurato) var response = await _httpClient.GetAsync(queryEndpoint, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogDebug($"SOQL Query failed: {response.StatusCode}"); _logger.LogDebug($"Error details: {errorContent}"); return new List>(); } var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogDebug($"SOQL Response: {responseContent}"); var queryResponse = JsonSerializer.Deserialize(responseContent, SalesforceJsonOptions); if (queryResponse?.Records != null && queryResponse.Records.Any()) { var results = queryResponse.Records.Select(record => record as Dictionary ?? new Dictionary() ).ToList(); _logger.LogDebug($"✅ Trovati {results.Count} record tramite SOQL"); foreach (var result in results) { _logger.LogDebug($" - Id: {result.GetValueOrDefault("Id")}, Campi: {string.Join(", ", result.Keys)}"); } return results; } _logger.LogDebug("Nessun record trovato"); return new List>(); } catch (Exception ex) { _logger.LogDebug($"❌ Errore durante la ricerca Salesforce: {ex.Message}"); _logger.LogDebug($"Stack Trace: {ex.StackTrace}"); return new List>(); } } /// /// Executes multiple queries using Salesforce Composite API for efficient data extraction /// /// List of SOQL queries to execute /// Cancellation token /// List of query results mapped by query index public async Task> BatchExecuteQueriesAsync(List queries, CancellationToken cancellationToken = default) { if (!IsAuthenticated()) { _logger.LogDebug("Error: Not authenticated to Salesforce. Cannot perform batch queries."); return new List(); } if (!queries.Any()) { return new List(); } // Salesforce limit: max 25 operations per composite request const int maxBatchSize = 25; // Split into batches of 25 var batches = new List<(List batch, int startIndex, int batchNumber)>(); for (int i = 0; i < queries.Count; i += maxBatchSize) { var batch = queries.Skip(i).Take(maxBatchSize).ToList(); var batchNumber = (i / maxBatchSize) + 1; batches.Add((batch, i, batchNumber)); } var totalBatches = batches.Count; _logger.LogDebug($"--- Starting parallel execution of {totalBatches} query batch(es) with {queries.Count} total queries ---"); // Execute all batches in parallel var batchTasks = batches.Select(async b => { _logger.LogDebug($"--- Processing Query Batch {b.batchNumber}/{totalBatches}: {b.batch.Count} queries (parallel) ---"); return await ExecuteQueryBatchAsync(b.batch, b.startIndex, cancellationToken); }); var batchResults = await Task.WhenAll(batchTasks); // Aggregate all results maintaining original order var allResults = new List(); foreach (var result in batchResults) { allResults.AddRange(result); } _logger.LogDebug($"All query batches completed: {allResults.Count(r => r.Success)} success, {allResults.Count(r => !r.Success)} failed"); return allResults.OrderBy(r => r.QueryIndex).ToList(); } private async Task> ExecuteQueryBatchAsync(List queries, int startIndex, CancellationToken cancellationToken) { try { // Salesforce Composite API endpoint var compositeUri = $"{_instanceUrl}/services/data/v60.0/composite/"; // Build composite request var compositeRequest = new SalesforceCompositeRequest(); for (int i = 0; i < queries.Count; i++) { var encodedQuery = Uri.EscapeDataString(queries[i]); var subrequest = new SalesforceCompositeSubRequest { Method = "GET", Url = $"/services/data/v60.0/query/?q={encodedQuery}", ReferenceId = $"query_{startIndex + i}" }; compositeRequest.CompositeRequest.Add(subrequest); } var jsonContent = new StringContent( JsonSerializer.Serialize(compositeRequest, SalesforceJsonOptions), System.Text.Encoding.UTF8, "application/json" ); var response = await _httpClient.PostAsync(compositeUri, jsonContent, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogDebug($"Salesforce Batch Query failed: {response.StatusCode}"); _logger.LogDebug($"Error details: {errorContent}"); // Return error results for all queries in this batch return queries.Select((query, index) => new BatchQueryResult { QueryIndex = startIndex + index, Query = query, Success = false, ErrorMessage = $"Batch operation failed: {response.StatusCode} - {errorContent}", Records = new List>() }).ToList(); } var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); var compositeResponse = JsonSerializer.Deserialize(responseContent, SalesforceJsonOptions); var results = new List(); if (compositeResponse?.CompositeResponse != null) { for (int i = 0; i < compositeResponse.CompositeResponse.Count; i++) { var subResponse = compositeResponse.CompositeResponse[i]; var originalQuery = i < queries.Count ? queries[i] : ""; var result = new BatchQueryResult { QueryIndex = startIndex + i, Query = originalQuery, Success = subResponse.HttpStatusCode >= 200 && subResponse.HttpStatusCode < 300 }; if (result.Success && subResponse.Body != null) { try { if (subResponse.Body is JsonElement bodyElement) { var queryResponse = JsonSerializer.Deserialize(bodyElement.GetRawText(), SalesforceJsonOptions); result.Records = queryResponse?.Records ?? new List>(); result.TotalSize = queryResponse?.TotalSize ?? 0; result.NextRecordsUrl = queryResponse?.NextRecordsUrl; } } catch (JsonException ex) { result.Success = false; result.ErrorMessage = $"Failed to parse query response: {ex.Message}"; result.Records = new List>(); } } else { result.ErrorMessage = subResponse.Body?.ToString() ?? "Unknown error"; result.Records = new List>(); } results.Add(result); } } return results; } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce batch query: {ex.Message}"); // Return error results for all queries in this batch return queries.Select((query, index) => new BatchQueryResult { QueryIndex = startIndex + index, Query = query, Success = false, ErrorMessage = ex.Message, Records = new List>() }).ToList(); } } /// /// Finds multiple entities by different key fields using batch queries for improved performance /// /// The name of the SObject to search /// List of key field combinations to search for /// Cancellation token /// Dictionary mapping original search index to found entities public async Task>>> BatchFindEntitiesByKeysAsync(string entityName, List> keyFieldsList, CancellationToken cancellationToken = default) { try { _logger.LogDebug($"--- Starting Salesforce Batch Entity Search: {entityName} ---"); _logger.LogDebug($"Searching for {keyFieldsList.Count} different key combinations"); if (!await EnsureAuthenticatedAsync(cancellationToken)) { _logger.LogDebug("Authentication failed for batch entity search"); return new Dictionary>>(); } // Build queries for each key field combination var queries = new List(); for (int i = 0; i < keyFieldsList.Count; i++) { var keyFields = keyFieldsList[i]; if (!keyFields.Any()) continue; var whereConditions = keyFields.Select(kvp => { var value = kvp.Value?.ToString() ?? ""; if (kvp.Value is string) { value = $"'{value.Replace("'", "\\'")}'"; } return $"{kvp.Key} = {value}"; }); var query = $"SELECT Id FROM {entityName} WHERE {string.Join(" AND ", whereConditions)}"; queries.Add(query); _logger.LogDebug($"Query {i}: {query}"); } if (!queries.Any()) { _logger.LogDebug("No valid queries generated for batch search"); return new Dictionary>>(); } // Execute batch queries var batchResults = await BatchExecuteQueriesAsync(queries, cancellationToken); // Map results back to original indices var results = new Dictionary>>(); foreach (var batchResult in batchResults) { results[batchResult.QueryIndex] = batchResult.Records; } var totalFound = results.Values.Sum(list => list.Count); _logger.LogDebug($"Batch entity search completed: {totalFound} total entities found across {results.Count} queries"); return results; } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce batch entity search: {ex.Message}"); return new Dictionary>>(); } } /// /// Extracts multiple entities by their IDs using batch queries /// /// The name of the SObject to retrieve /// List of entity IDs to retrieve /// Fields to select (if null, selects all fields) /// Cancellation token /// List of retrieved entities public async Task>> BatchGetEntitiesByIdsAsync(string entityName, List entityIds, List? fieldsToSelect = null, CancellationToken cancellationToken = default) { try { _logger.LogDebug($"--- Starting Salesforce Batch Entity Retrieval: {entityName} ---"); _logger.LogDebug($"Retrieving {entityIds.Count} entities by ID"); if (!await EnsureAuthenticatedAsync(cancellationToken)) { _logger.LogDebug("Authentication failed for batch entity retrieval"); return new List>(); } if (!entityIds.Any()) { return new List>(); } // Determine fields to select string selectFields = "*"; if (fieldsToSelect?.Any() == true) { selectFields = string.Join(", ", fieldsToSelect); } else { // If no specific fields requested, get basic fields plus Id selectFields = "Id"; // Start with Id, add more fields if needed // Optionally, you could get entity metadata to select all fields // For now, we'll use a basic set of common fields var commonFields = new[] { "Name", "CreatedDate", "LastModifiedDate" }; selectFields = $"Id, {string.Join(", ", commonFields)}"; } // Create batch queries - we can use IN clause to group multiple IDs per query // Salesforce SOQL IN clause can handle up to 4000 characters const int maxIdsPerQuery = 200; // Conservative limit to avoid URL length issues var queries = new List(); for (int i = 0; i < entityIds.Count; i += maxIdsPerQuery) { var batchIds = entityIds.Skip(i).Take(maxIdsPerQuery); var idList = string.Join("','", batchIds.Select(id => id.Replace("'", "\\'"))); var query = $"SELECT {selectFields} FROM {entityName} WHERE Id IN ('{idList}')"; queries.Add(query); } _logger.LogDebug($"Created {queries.Count} batch queries for {entityIds.Count} entity IDs"); // Execute batch queries var batchResults = await BatchExecuteQueriesAsync(queries, cancellationToken); // Aggregate all records from all queries var allRecords = new List>(); foreach (var batchResult in batchResults.Where(r => r.Success)) { allRecords.AddRange(batchResult.Records); } _logger.LogDebug($"Successfully retrieved {allRecords.Count} entities out of {entityIds.Count} requested"); return allRecords; } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce batch entity retrieval: {ex.Message}"); return new List>(); } } /// /// Extracts all records from an entity using pagination and batch processing /// /// The name of the SObject to extract /// Specific fields to select (if null, uses common fields) /// Optional WHERE clause for filtering /// Maximum number of records to retrieve (0 = no limit) /// Cancellation token /// List of all extracted records public async Task>> ExtractAllEntitiesAsync(string entityName, List? fieldsToSelect = null, string? whereClause = null, int maxRecords = 0, CancellationToken cancellationToken = default) { try { _logger.LogDebug($"--- Starting Salesforce Full Entity Extraction: {entityName} ---"); if (!await EnsureAuthenticatedAsync(cancellationToken)) { _logger.LogDebug("Authentication failed for entity extraction"); return new List>(); } // Determine fields to select string selectFields = "*"; if (fieldsToSelect?.Any() == true) { selectFields = string.Join(", ", fieldsToSelect); } else { // Use common fields if none specified selectFields = "Id"; // Always include Id // Get entity details to determine available fields var entityDetails = await DiscoverEntityDetailsAsync(entityName, cancellationToken); if (entityDetails?.Properties?.Any() == true) { // Select up to 10 most common fields to avoid URL length limits var availableFields = entityDetails.Properties .Where(p => !string.IsNullOrEmpty(p.Name)) .Take(10) .Select(p => p.Name); selectFields = string.Join(", ", availableFields); } } // Build initial query var query = $"SELECT {selectFields} FROM {entityName}"; if (!string.IsNullOrWhiteSpace(whereClause)) { query += $" WHERE {whereClause}"; } query += " ORDER BY Id"; // Add ordering for consistent pagination _logger.LogDebug($"Initial extraction query: {query}"); var allRecords = new List>(); var currentQuery = query; var hasMore = true; var pageCount = 0; while (hasMore && (maxRecords == 0 || allRecords.Count < maxRecords)) { pageCount++; _logger.LogDebug($"Extracting page {pageCount}..."); var encodedQuery = Uri.EscapeDataString(currentQuery); var queryEndpoint = $"/services/data/v60.0/query/?q={encodedQuery}"; var response = await GetAsync($"{_instanceUrl}{queryEndpoint}", cancellationToken); if (response?.Records != null) { var pageRecords = response.Records; // Apply max records limit if specified if (maxRecords > 0) { var remainingCapacity = maxRecords - allRecords.Count; if (remainingCapacity < pageRecords.Count) { pageRecords = pageRecords.Take(remainingCapacity).ToList(); } } allRecords.AddRange(pageRecords); _logger.LogDebug($"Page {pageCount}: Retrieved {pageRecords.Count} records, total: {allRecords.Count}"); // Check if there are more records hasMore = !response.Done && !string.IsNullOrEmpty(response.NextRecordsUrl); if (hasMore && response.NextRecordsUrl != null) { // Use the nextRecordsUrl for the next query currentQuery = response.NextRecordsUrl.Replace($"{_instanceUrl}/services/data/v60.0/query/", ""); currentQuery = Uri.UnescapeDataString(currentQuery); } } else { _logger.LogDebug($"No response received for page {pageCount}"); hasMore = false; } // Prevent infinite loops if (pageCount > 1000) { _logger.LogDebug("Maximum page limit reached (1000), stopping extraction"); break; } } _logger.LogDebug($"Entity extraction completed: {allRecords.Count} total records extracted in {pageCount} pages"); return allRecords; } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce entity extraction: {ex.Message}"); return new List>(); } } /// /// Extracts entities using multiple parallel queries with different criteria /// Useful for extracting large datasets by splitting them by date ranges or other criteria /// /// The name of the SObject to extract /// Specific fields to select /// List of WHERE clauses for parallel extraction /// Maximum records per individual query /// Cancellation token /// Combined list of all extracted records public async Task>> ExtractEntitiesParallelAsync(string entityName, List? fieldsToSelect, List whereClauses, int maxRecordsPerQuery = 0, CancellationToken cancellationToken = default) { try { _logger.LogDebug($"--- Starting Salesforce Parallel Entity Extraction: {entityName} ---"); _logger.LogDebug($"Using {whereClauses.Count} parallel extraction criteria"); if (!await EnsureAuthenticatedAsync(cancellationToken)) { _logger.LogDebug("Authentication failed for parallel entity extraction"); return new List>(); } // Determine fields to select string selectFields = "Id"; if (fieldsToSelect?.Any() == true) { selectFields = string.Join(", ", fieldsToSelect); } // Create extraction tasks for each WHERE clause var extractionTasks = whereClauses.Select(async (whereClause, index) => { try { _logger.LogDebug($"Starting parallel extraction {index + 1}/{whereClauses.Count}: {whereClause}"); var records = await ExtractAllEntitiesAsync(entityName, fieldsToSelect, whereClause, maxRecordsPerQuery, cancellationToken); _logger.LogDebug($"Parallel extraction {index + 1} completed: {records.Count} records"); return records; } catch (Exception ex) { _logger.LogDebug($"Error in parallel extraction {index + 1}: {ex.Message}"); return new List>(); } }).ToList(); // Wait for all parallel extractions to complete var allResults = await Task.WhenAll(extractionTasks); // Combine all results var combinedRecords = new List>(); foreach (var result in allResults) { combinedRecords.AddRange(result); } // Remove potential duplicates based on Id field var deduplicatedRecords = combinedRecords .GroupBy(record => record.ContainsKey("Id") ? record["Id"]?.ToString() : Guid.NewGuid().ToString()) .Select(group => group.First()) .ToList(); var duplicatesRemoved = combinedRecords.Count - deduplicatedRecords.Count; if (duplicatesRemoved > 0) { _logger.LogDebug($"Removed {duplicatesRemoved} duplicate records"); } _logger.LogDebug($"Parallel entity extraction completed: {deduplicatedRecords.Count} unique records from {whereClauses.Count} parallel queries"); return deduplicatedRecords; } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce parallel entity extraction: {ex.Message}"); return new List>(); } } /// /// Helper method to split large datasets into date-based chunks for parallel extraction /// /// Start date for extraction /// End date for extraction /// Name of the date field to use for splitting (e.g., "CreatedDate", "LastModifiedDate") /// Size of each date chunk in days /// List of WHERE clauses for parallel extraction public List CreateDateBasedWhereClauses(DateTime startDate, DateTime endDate, string dateFieldName = "CreatedDate", int chunkSizeInDays = 30) { var whereClauses = new List(); var currentDate = startDate; while (currentDate < endDate) { var chunkEndDate = currentDate.AddDays(chunkSizeInDays); if (chunkEndDate > endDate) { chunkEndDate = endDate; } var startDateString = currentDate.ToString("yyyy-MM-ddTHH:mm:ssZ"); var endDateString = chunkEndDate.ToString("yyyy-MM-ddTHH:mm:ssZ"); var whereClause = $"{dateFieldName} >= {startDateString} AND {dateFieldName} < {endDateString}"; whereClauses.Add(whereClause); currentDate = chunkEndDate; } _logger.LogDebug($"Created {whereClauses.Count} date-based chunks for parallel extraction between {startDate:yyyy-MM-dd} and {endDate:yyyy-MM-dd}"); return whereClauses; } /// /// High-performance method to extract large datasets by automatically splitting them into optimal chunks /// /// The name of the SObject to extract /// Specific fields to select /// Base WHERE clause (optional) /// Maximum total records to extract (0 = no limit) /// Cancellation token /// List of all extracted records public async Task>> ExtractLargeDatasetAsync(string entityName, List? fieldsToSelect = null, string? baseWhereClause = null, int maxRecords = 0, CancellationToken cancellationToken = default) { try { _logger.LogDebug($"--- Starting Large Dataset Extraction: {entityName} ---"); // First, try to get a count to determine if we need parallel processing var countQuery = $"SELECT COUNT() FROM {entityName}"; if (!string.IsNullOrWhiteSpace(baseWhereClause)) { countQuery += $" WHERE {baseWhereClause}"; } _logger.LogDebug($"Checking dataset size with query: {countQuery}"); try { var countResponse = await GetAsync>($"{_instanceUrl}/services/data/v60.0/query/?q={Uri.EscapeDataString(countQuery)}", cancellationToken); if (countResponse?.ContainsKey("totalSize") == true && int.TryParse(countResponse["totalSize"].ToString(), out int totalRecords)) { _logger.LogDebug($"Dataset contains approximately {totalRecords} records"); // If dataset is large (>10,000 records), use parallel extraction if (totalRecords > 10000) { _logger.LogDebug("Large dataset detected, using parallel extraction with date-based chunking"); // Create date-based chunks for the last 2 years by default var endDate = DateTime.UtcNow; var startDate = endDate.AddYears(-2); var whereClauses = CreateDateBasedWhereClauses(startDate, endDate, "CreatedDate", 7); // 7-day chunks // Add base WHERE clause to each chunk if provided if (!string.IsNullOrWhiteSpace(baseWhereClause)) { whereClauses = whereClauses.Select(wc => $"({baseWhereClause}) AND ({wc})").ToList(); } return await ExtractEntitiesParallelAsync(entityName, fieldsToSelect, whereClauses, maxRecords / whereClauses.Count, cancellationToken); } } } catch (Exception ex) { _logger.LogDebug($"Could not determine dataset size, proceeding with standard extraction: {ex.Message}"); } // For smaller datasets or if count failed, use standard extraction _logger.LogDebug("Using standard sequential extraction"); return await ExtractAllEntitiesAsync(entityName, fieldsToSelect, baseWhereClause, maxRecords, cancellationToken); } catch (Exception ex) { _logger.LogDebug($"Error during large dataset extraction: {ex.Message}"); return new List>(); } } /// /// Optimized method to extract recently modified entities using batch operations /// /// The name of the SObject to extract /// Specific fields to select /// Number of hours back to look for modifications (default: 24) /// Cancellation token /// List of recently modified entities public async Task>> ExtractRecentlyModifiedAsync(string entityName, List? fieldsToSelect = null, int hoursBack = 24, CancellationToken cancellationToken = default) { try { var startTime = DateTime.UtcNow.AddHours(-hoursBack); var whereClause = $"LastModifiedDate >= {startTime:yyyy-MM-ddTHH:mm:ssZ}"; _logger.LogDebug($"--- Extracting recently modified {entityName} (last {hoursBack} hours) ---"); _logger.LogDebug($"Using WHERE clause: {whereClause}"); return await ExtractAllEntitiesAsync(entityName, fieldsToSelect, whereClause, 0, cancellationToken); } catch (Exception ex) { _logger.LogDebug($"Error during recent entities extraction: {ex.Message}"); return new List>(); } } /// /// Deletes an entity in Salesforce by its ID. /// /// The name of the SObject (e.g., "Account", "Contact"). /// The ID of the entity to delete. /// Cancellation token. /// True if deletion was successful, false otherwise. public override async Task DeleteEntityAsync(string entityName, string entityId, CancellationToken cancellationToken = default) { try { _logger.LogDebug($"--- Starting Salesforce Entity Delete: {entityName}/{entityId} ---"); if (!await EnsureAuthenticatedAsync(cancellationToken)) { _logger.LogDebug("Authentication failed for entity deletion"); return false; } var deleteEndpoint = $"/services/data/v59.0/sobjects/{entityName}/{entityId}"; // Salesforce usa DELETE HTTP method per eliminare record var request = new HttpRequestMessage(HttpMethod.Delete, $"{_instanceUrl}{deleteEndpoint}"); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); var response = await _httpClient.SendAsync(request, cancellationToken); if (response.IsSuccessStatusCode) { _logger.LogDebug($"Entity {entityName}/{entityId} deleted successfully"); return true; } else { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogDebug($"Failed to delete entity {entityName}/{entityId}. Status: {response.StatusCode}, Error: {errorContent}"); return false; } } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce entity deletion: {ex.Message}"); return false; } } /// /// Updates an existing entity in Salesforce by its ID. /// /// The name of the SObject (e.g., "Account", "Contact"). /// The ID of the entity to update. /// The data to update as key-value pairs. /// Cancellation token. /// The updated entity data or null if update failed. public override async Task?> UpdateEntityAsync(string entityName, string entityId, Dictionary entityData, CancellationToken cancellationToken = default) { try { _logger.LogDebug($"--- Starting Salesforce Entity Update: {entityName}/{entityId} ---"); if (!await EnsureAuthenticatedAsync(cancellationToken)) { _logger.LogDebug("Authentication failed for entity update"); return null; } var updateEndpoint = $"/services/data/v59.0/sobjects/{entityName}/{entityId}"; // Salesforce usa PATCH HTTP method per aggiornare record var request = new HttpRequestMessage(HttpMethod.Patch, $"{_instanceUrl}{updateEndpoint}"); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); request.Content = JsonContent.Create(entityData); var response = await _httpClient.SendAsync(request, cancellationToken); if (response.IsSuccessStatusCode) { _logger.LogDebug($"Entity {entityName}/{entityId} updated successfully"); // Ritorna i dati aggiornati includendo l'ID var updatedData = new Dictionary(entityData) { ["Id"] = entityId }; return updatedData; } else { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogDebug($"Failed to update entity {entityName}/{entityId}. Status: {response.StatusCode}, Error: {errorContent}"); return null; } } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce entity update: {ex.Message}"); return null; } } /// /// Ensures the client is authenticated, attempting to authenticate if not already authenticated. /// /// Cancellation token. /// True if authentication is successful, false otherwise. private async Task EnsureAuthenticatedAsync(CancellationToken cancellationToken = default) { if (IsAuthenticated()) { return true; } _logger.LogDebug("Client not authenticated, attempting to authenticate..."); return await AuthenticateAsync(cancellationToken); } /// /// Normalizza i valori numerici in un dictionary per garantire compatibilità con Salesforce API /// Converte valori decimali con virgola in formato con punto decimale /// private Dictionary NormalizeNumericValues(Dictionary data) { var normalizedData = new Dictionary(); bool hasNormalized = false; foreach (var kvp in data) { var value = kvp.Value; if (value != null) { // Se è una stringa che rappresenta un numero decimale con virgola, convertila if (value is string stringValue && IsNumericWithComma(stringValue)) { if (decimal.TryParse(stringValue, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.CurrentCulture, out decimal decimalValue)) { // Converte in double usando cultura invariante per garantire punto decimale var normalizedValue = double.Parse(decimalValue.ToString(System.Globalization.CultureInfo.InvariantCulture)); normalizedData[kvp.Key] = normalizedValue; _logger.LogDebug($"NUMERIC NORMALIZATION: {kvp.Key}: '{stringValue}' → {normalizedValue}"); hasNormalized = true; } else { normalizedData[kvp.Key] = value; } } // Se è già un decimal, convertilo in double con cultura invariante else if (value is decimal dec) { var normalizedValue = double.Parse(dec.ToString(System.Globalization.CultureInfo.InvariantCulture)); normalizedData[kvp.Key] = normalizedValue; _logger.LogDebug($"DECIMAL NORMALIZATION: {kvp.Key}: {dec} → {normalizedValue}"); hasNormalized = true; } else { normalizedData[kvp.Key] = value; } } else { normalizedData[kvp.Key] = value!; } } if (hasNormalized) { _logger.LogDebug($"NORMALIZATION SUMMARY: Processed {data.Count} fields, normalized {normalizedData.Count(kvp => kvp.Value is double)} numeric values"); } return normalizedData; } /// /// Verifica se una stringa rappresenta un numero con virgola decimale /// private static bool IsNumericWithComma(string value) { if (string.IsNullOrEmpty(value)) return false; // Pattern per numeri con virgola: opzionale segno, cifre, virgola, cifre return System.Text.RegularExpressions.Regex.IsMatch(value.Trim(), @"^[+-]?\d+,\d+$"); } // --- Nested classes for deserializing Salesforce responses --- private class SalesforceTokenResponse { [JsonPropertyName("access_token")] public string AccessToken { get; set; } = string.Empty; [JsonPropertyName("instance_url")] public string InstanceUrl { get; set; } = string.Empty; [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; [JsonPropertyName("token_type")] public string TokenType { get; set; } = string.Empty; [JsonPropertyName("issued_at")] public string IssuedAt { get; set; } = string.Empty; [JsonPropertyName("signature")] public string Signature { get; set; } = string.Empty; } private class SalesforceSObjectsResponse { [JsonPropertyName("encoding")] public string Encoding { get; set; } = string.Empty; [JsonPropertyName("maxBatchSize")] public int MaxBatchSize { get; set; } [JsonPropertyName("sobjects")] public List SObjects { get; set; } = new List(); } private class SalesforceSObject { [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; [JsonPropertyName("label")] public string Label { get; set; } = string.Empty; [JsonPropertyName("custom")] public bool Custom { get; set; } [JsonPropertyName("keyPrefix")] public string KeyPrefix { get; set; } = string.Empty; } private class SalesforceDescribeResponse { [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; [JsonPropertyName("label")] public string Label { get; set; } = string.Empty; [JsonPropertyName("fields")] public List Fields { get; set; } = new List(); } /// /// Finds entities by required fields to detect duplicates. /// Now uses batch operations for improved performance when checking multiple field combinations. /// /// The name of the entity to search. /// The required fields and their values to search for. /// Cancellation token. /// A list of matching entities. public override async Task>> FindEntitiesByRequiredFieldsAsync(string entityName, Dictionary requiredFields, CancellationToken cancellationToken = default) { try { _logger.LogDebug($"--- Searching for duplicates in {entityName} by required fields ---"); if (!await EnsureAuthenticatedAsync(cancellationToken)) { _logger.LogDebug("Authentication failed for required fields search"); return new List>(); } if (!requiredFields.Any()) { _logger.LogDebug("No required fields provided for duplicate search"); return new List>(); } // Use the new batch search functionality for a single key field combination var keyFieldsList = new List> { requiredFields }; var batchResults = await BatchFindEntitiesByKeysAsync(entityName, keyFieldsList, cancellationToken); // Extract results for the single query (index 0) if (batchResults.ContainsKey(0)) { var results = batchResults[0]; _logger.LogDebug($"Found {results.Count} potential duplicates for required fields: {string.Join(", ", requiredFields.Select(kv => $"{kv.Key}={kv.Value}"))}"); return results; } _logger.LogDebug("No duplicates found"); return new List>(); } catch (Exception ex) { _logger.LogDebug($"Error during required fields search: {ex.Message}"); return new List>(); } } private class SalesforceField { [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; [JsonPropertyName("label")] public string Label { get; set; } = string.Empty; [JsonPropertyName("length")] public int Length { get; set; } [JsonPropertyName("nillable")] public bool Nillable { get; set; } [JsonPropertyName("unique")] public bool Unique { get; set; } } private class SalesforceQueryResponse { [JsonPropertyName("records")] public List> Records { get; set; } = new List>(); [JsonPropertyName("totalSize")] public int TotalSize { get; set; } [JsonPropertyName("done")] public bool Done { get; set; } [JsonPropertyName("nextRecordsUrl")] public string? NextRecordsUrl { get; set; } } public class BatchQueryResult { public int QueryIndex { get; set; } public string Query { get; set; } = string.Empty; public bool Success { get; set; } public string ErrorMessage { get; set; } = string.Empty; public List> Records { get; set; } = new List>(); public int TotalSize { get; set; } public string? NextRecordsUrl { get; set; } } // ===== Composite Batch API models (for parallel describe calls) ===== private class SalesforceBatchDescribeRequest { [JsonPropertyName("batchRequests")] public List 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 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 { [JsonPropertyName("compositeRequest")] public List CompositeRequest { get; set; } = new List(); } private class SalesforceCompositeSubRequest { [JsonPropertyName("method")] public string Method { get; set; } = string.Empty; [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; [JsonPropertyName("referenceId")] public string ReferenceId { get; set; } = string.Empty; [JsonPropertyName("body")] public object Body { get; set; } = new object(); } private class SalesforceCompositeResponse { [JsonPropertyName("compositeResponse")] public List CompositeResponse { get; set; } = new List(); } private class SalesforceCompositeSubResponse { [JsonPropertyName("referenceId")] public string ReferenceId { get; set; } = string.Empty; [JsonPropertyName("httpStatusCode")] public int HttpStatusCode { get; set; } [JsonPropertyName("body")] public object Body { get; set; } = new object(); } public class CompositeOperationResult { public string ReferenceId { get; set; } = string.Empty; public string? EntityId { get; set; } public int HttpStatusCode { get; set; } public bool Success { get; set; } public string ErrorMessage { get; set; } = string.Empty; public Dictionary? CreatedData { get; set; } public Dictionary? UpdatedData { get; set; } } /// /// Executes multiple create operations using Salesforce Composite API with automatic batching /// /// The name of the SObject to create /// List of entity data to create /// Cancellation token /// List of results for each create operation public async Task> BatchCreateEntitiesAsync(string entityName, List> entityDataList, CancellationToken cancellationToken = default) { if (!IsAuthenticated()) { _logger.LogDebug("Error: Not authenticated to Salesforce. Cannot perform batch create."); return new List(); } if (!entityDataList.Any()) { return new List(); } // Salesforce limit: max 25 operations per composite request const int maxBatchSize = 25; // Split into batches of 25 var batches = new List<(List> batch, int startIndex, int batchNumber)>(); for (int i = 0; i < entityDataList.Count; i += maxBatchSize) { var batch = entityDataList.Skip(i).Take(maxBatchSize).ToList(); var batchNumber = (i / maxBatchSize) + 1; batches.Add((batch, i, batchNumber)); } var totalBatches = batches.Count; _logger.LogDebug($"--- Starting parallel processing of {totalBatches} batch(es) with {entityDataList.Count} total records ---"); // Execute all batches in parallel var batchTasks = batches.Select(async b => { _logger.LogDebug($"--- Processing Batch {b.batchNumber}/{totalBatches}: {b.batch.Count} records (parallel) ---"); return await ExecuteCreateBatchAsync(entityName, b.batch, b.startIndex, cancellationToken); }); var batchResults = await Task.WhenAll(batchTasks); // Aggregate all results var allResults = new List(); foreach (var result in batchResults) { allResults.AddRange(result); } _logger.LogDebug($"All batches completed: {allResults.Count(r => r.Success)} success, {allResults.Count(r => !r.Success)} failed"); return allResults; } private async Task> ExecuteCreateBatchAsync(string entityName, List> batch, int startIndex, CancellationToken cancellationToken) { try { // Salesforce Composite API endpoint var compositeUri = $"{_instanceUrl}/services/data/v60.0/composite/"; // Build composite request var compositeRequest = new SalesforceCompositeRequest(); for (int i = 0; i < batch.Count; i++) { // Normalizza i valori numerici per evitare problemi con virgole decimali var normalizedData = NormalizeNumericValues(batch[i]); var subrequest = new SalesforceCompositeSubRequest { Method = "POST", Url = $"/services/data/v60.0/sobjects/{entityName}/", ReferenceId = $"create_{startIndex + i}", Body = normalizedData }; compositeRequest.CompositeRequest.Add(subrequest); } // Usa StringContent con configurazione JSON specifica per Salesforce var jsonContent = new StringContent( JsonSerializer.Serialize(compositeRequest, SalesforceJsonOptions), System.Text.Encoding.UTF8, "application/json" ); var response = await _httpClient.PostAsync(compositeUri, jsonContent, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogDebug($"Salesforce Batch Create failed: {response.StatusCode}"); _logger.LogDebug($"Error details: {errorContent}"); // Return error results for all operations in this batch return batch.Select((_, index) => new CompositeOperationResult { ReferenceId = $"create_{startIndex + index}", Success = false, ErrorMessage = $"Batch operation failed: {response.StatusCode} - {errorContent}" }).ToList(); } var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); var compositeResponse = JsonSerializer.Deserialize(responseContent, SalesforceJsonOptions); var results = new List(); if (compositeResponse?.CompositeResponse != null) { foreach (var subResponse in compositeResponse.CompositeResponse) { var result = new CompositeOperationResult { ReferenceId = subResponse.ReferenceId, HttpStatusCode = subResponse.HttpStatusCode, Success = subResponse.HttpStatusCode >= 200 && subResponse.HttpStatusCode < 300 }; if (result.Success && subResponse.Body != null) { if (subResponse.Body is JsonElement bodyElement) { var bodyDict = JsonSerializer.Deserialize>(bodyElement.GetRawText(), SalesforceJsonOptions); result.CreatedData = bodyDict; // Extract the created ID if (bodyDict?.ContainsKey("id") == true) { result.EntityId = bodyDict["id"]?.ToString(); } } } else { result.ErrorMessage = subResponse.Body?.ToString() ?? "Unknown error"; } results.Add(result); } } return results; } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce batch create: {ex.Message}"); // Return error results for all operations in this batch return batch.Select((_, index) => new CompositeOperationResult { ReferenceId = $"create_{startIndex + index}", Success = false, ErrorMessage = ex.Message }).ToList(); } } /// /// Executes multiple update operations using Salesforce Composite API with automatic batching /// /// The name of the SObject to update /// Dictionary where key is entityId and value is the data to update /// Cancellation token /// List of results for each update operation public async Task> BatchUpdateEntitiesAsync(string entityName, Dictionary> updateData, CancellationToken cancellationToken = default) { if (!IsAuthenticated()) { _logger.LogDebug("Error: Not authenticated to Salesforce. Cannot perform batch update."); return new List(); } if (!updateData.Any()) { return new List(); } // Salesforce limit: max 25 operations per composite request const int maxBatchSize = 25; var updateList = updateData.ToList(); // Split into batches of 25 var batches = new List<(Dictionary> batch, int startIndex, int batchNumber)>(); for (int i = 0; i < updateList.Count; i += maxBatchSize) { var batch = updateList.Skip(i).Take(maxBatchSize).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); var batchNumber = (i / maxBatchSize) + 1; batches.Add((batch, i, batchNumber)); } var totalBatches = batches.Count; _logger.LogDebug($"--- Starting parallel processing of {totalBatches} update batch(es) with {updateList.Count} total records ---"); // Execute all batches in parallel var batchTasks = batches.Select(async b => { _logger.LogDebug($"--- Processing Update Batch {b.batchNumber}/{totalBatches}: {b.batch.Count} records (parallel) ---"); return await ExecuteUpdateBatchAsync(entityName, b.batch, b.startIndex, cancellationToken); }); var batchResults = await Task.WhenAll(batchTasks); // Aggregate all results var allResults = new List(); foreach (var result in batchResults) { allResults.AddRange(result); } _logger.LogDebug($"All update batches completed: {allResults.Count(r => r.Success)} success, {allResults.Count(r => !r.Success)} failed"); return allResults; } private async Task> ExecuteUpdateBatchAsync(string entityName, Dictionary> batch, int startIndex, CancellationToken cancellationToken) { try { // Salesforce Composite API endpoint var compositeUri = $"{_instanceUrl}/services/data/v60.0/composite/"; // Build composite request var compositeRequest = new SalesforceCompositeRequest(); int index = 0; foreach (var kvp in batch) { var entityId = kvp.Key; var entityData = kvp.Value; // Normalizza i valori numerici per evitare problemi con virgole decimali var normalizedData = NormalizeNumericValues(entityData); var subrequest = new SalesforceCompositeSubRequest { Method = "PATCH", Url = $"/services/data/v60.0/sobjects/{entityName}/{entityId}", ReferenceId = $"update_{startIndex + index}", Body = normalizedData }; compositeRequest.CompositeRequest.Add(subrequest); index++; } // Usa StringContent con configurazione JSON specifica per Salesforce var jsonContent = new StringContent( JsonSerializer.Serialize(compositeRequest, SalesforceJsonOptions), System.Text.Encoding.UTF8, "application/json" ); var response = await _httpClient.PostAsync(compositeUri, jsonContent, cancellationToken); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogDebug($"Salesforce Batch Update failed: {response.StatusCode}"); _logger.LogDebug($"Error details: {errorContent}"); // Return error results for all operations in this batch return batch.Select((kvp, idx) => new CompositeOperationResult { ReferenceId = $"update_{startIndex + idx}", EntityId = kvp.Key, Success = false, ErrorMessage = $"Batch operation failed: {response.StatusCode} - {errorContent}" }).ToList(); } var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); var compositeResponse = JsonSerializer.Deserialize(responseContent, SalesforceJsonOptions); var results = new List(); if (compositeResponse?.CompositeResponse != null) { int resultIndex = 0; foreach (var subResponse in compositeResponse.CompositeResponse) { var originalEntityId = batch.ElementAt(resultIndex).Key; var result = new CompositeOperationResult { ReferenceId = subResponse.ReferenceId, EntityId = originalEntityId, HttpStatusCode = subResponse.HttpStatusCode, Success = subResponse.HttpStatusCode >= 200 && subResponse.HttpStatusCode < 300 }; if (result.Success) { // For successful updates, create updated data with the ID var originalData = batch.ElementAt(resultIndex).Value; result.UpdatedData = new Dictionary(originalData) { ["Id"] = originalEntityId }; } else { result.ErrorMessage = subResponse.Body?.ToString() ?? "Unknown error"; } results.Add(result); resultIndex++; } } return results; } catch (Exception ex) { _logger.LogDebug($"Error during Salesforce batch update: {ex.Message}"); // Return error results for all operations in this batch return batch.Select((kvp, idx) => new CompositeOperationResult { ReferenceId = $"update_{startIndex + idx}", EntityId = kvp.Key, Success = false, ErrorMessage = ex.Message }).ToList(); } } } }