b75e57fe31
Implementato il flusso OAuth2 grant_type=client_credentials come alternativa al flusso password gia' esistente per l'autenticazione Salesforce server-to-server. La modifica e' completamente retrocompatibile (default rimane Password). ## Dettaglio modifiche ### CredentialManager/Models/CredentialModels.cs - Aggiunto enum SalesforceGrantType con valori Password e ClientCredentials - Aggiunta proprieta' GrantType (default: Password) su RestApiCredential - Aggiunta proprieta' GrantType (default: Password) su SalesforceCredential ### DataConnection/REST/Configuration/RestServiceOptions.cs - Aggiunta proprieta' SalesforceGrantType per passare il tipo di flusso al client ### DataConnection/REST/Implementations/SalesforceServiceClient.cs - Iniettato ILogger<SalesforceServiceClient> con NullLogger come fallback - Sostituiti ~165 Console.WriteLine con chiamate ILogger appropriate (LogDebug per dettagli, LogInformation per eventi, LogWarning/LogError per problemi) - Aggiunto AuthenticateWithPasswordAsync: incapsula il flusso grant_type=password - Aggiunto AuthenticateWithClientCredentialsAsync: implementa grant_type=client_credentials (richiede solo ClientId e ClientSecret, nessun utente, URL My Domain obbligatorio) - Aggiunto SendTokenRequestAsync: helper condiviso per la POST al token endpoint - Aggiornato AuthenticateAsync() override: instrada al flusso corretto in base a GrantType - Rimosso modificatore static da NormalizeNumericValues (usava _logger, causava CS0120) ### Data_Coupler/Services/DataConnectionFactory.cs - Mappatura del campo GrantType dalle opzioni Salesforce a RestServiceOptions - Passaggio dell'ILogger al costruttore di SalesforceServiceClient ### CredentialManager/Services/CredentialService.cs - SaveRestApiCredentialAsync (blocco Salesforce): serializza GrantType in AdditionalParameters - SaveSalesforceCredentialAsync: aggiunto GrantType nel dizionario iniziale - MapToRestApiCredential: deserializza GrantType da AdditionalParameters con Enum.TryParse - MapToSalesforceCredential: idem per il tipo SalesforceCredential ### DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs - TestSalesforceOAuthLogin aggiornato: per ClientCredentials invia solo client_id e client_secret (senza username/password/security_token); per Password comportamento invariato ### Data_Coupler/Pages/CredentialManagement.razor - Aggiunto dropdown 'Tipo di Autenticazione OAuth2' nella sezione Salesforce - I campi Username, Password e Security Token vengono nascosti quando si seleziona il flusso ClientCredentials - Alert contestuale: warning My Domain URL per ClientCredentials, info per Password - GrantType propagato correttamente in EditRestApiCredential e TestRestApiConnectionFromModal ### AGENTS.md - Aggiunta sezione di documentazione per la nuova funzionalita' OAuth2 client_credentials
2166 lines
102 KiB
C#
2166 lines
102 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Client specific for Salesforce REST API.
|
|
/// Handles OAuth2 authentication and metadata discovery.
|
|
/// </summary>
|
|
public class SalesforceServiceClient : BaseRestServiceClient, IRestMetadataDiscovery
|
|
{
|
|
private string? _accessToken;
|
|
private string? _instanceUrl;
|
|
private DateTime _tokenExpiry;
|
|
private readonly ILogger<SalesforceServiceClient> _logger;
|
|
|
|
/// <summary>
|
|
/// Configurazione JSON per garantire la compatibilità con Salesforce API
|
|
/// Utilizza sempre la cultura invariante per i numeri per evitare problemi con virgole/punti decimali
|
|
/// </summary>
|
|
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<SalesforceServiceClient>? logger = null)
|
|
: base(httpClient, options)
|
|
{
|
|
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<SalesforceServiceClient>.Instance;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Authenticates with Salesforce using Username/Password OAuth2 flow (grant_type=password).
|
|
/// </summary>
|
|
public async Task<bool> 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<KeyValuePair<string, string>>
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<bool> 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<KeyValuePair<string, string>>
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a token request to the Salesforce OAuth2 endpoint and stores the resulting token.
|
|
/// </summary>
|
|
private async Task<bool> SendTokenRequestAsync(List<KeyValuePair<string, string>> 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<SalesforceTokenResponse>(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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Authenticates with Salesforce using the grant type configured in options.
|
|
/// Routes to <see cref="AuthenticateWithClientCredentialsAsync"/> or <see cref="AuthenticateWithPasswordAsync"/>
|
|
/// based on <see cref="RestServiceOptions.SalesforceGrantType"/>.
|
|
/// </summary>
|
|
public override async Task<bool> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if the current access token is active.
|
|
/// </summary>
|
|
public bool IsAuthenticated()
|
|
{
|
|
return !string.IsNullOrEmpty(_accessToken) && DateTime.UtcNow < _tokenExpiry;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Discovers SObjects (entities) and their fields from Salesforce.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>A list of discovered entity information</returns>
|
|
public async Task<List<RestEntityInfo>> DiscoverEntitiesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
_logger.LogDebug("Error: Not authenticated to Salesforce. Cannot discover metadata.");
|
|
return new List<RestEntityInfo>();
|
|
}
|
|
|
|
var entities = new List<RestEntityInfo>();
|
|
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<SalesforceSObjectsResponse>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Discovers a list of available SObjects from Salesforce without detailed field information.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>A list of discovered SObject summaries</returns>
|
|
public async Task<List<RestEntitySummary>> DiscoverEntitySummariesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
_logger.LogDebug("Error: Not authenticated to Salesforce. Cannot discover metadata.");
|
|
return new List<RestEntitySummary>();
|
|
}
|
|
|
|
var entities = new List<RestEntitySummary>();
|
|
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<SalesforceSObjectsResponse>(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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Discovers detailed information for a specific SObject including all its fields.
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the SObject to get details for</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Detailed SObject information or null if not found</returns>
|
|
public async Task<RestEntityInfo?> 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<SalesforceDescribeResponse>(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;
|
|
}
|
|
|
|
/// <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));
|
|
}
|
|
|
|
_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<string, SalesforceDescribeResponse?>(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<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)
|
|
{
|
|
_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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new SObject in Salesforce.
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the SObject to create (e.g., "Account", "Contact").</param>
|
|
/// <param name="entityData">The data for the new SObject as key-value pairs.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The created entity data or null if creation failed.</returns>
|
|
public override async Task<Dictionary<string, object>?> CreateEntityAsync(string entityName, Dictionary<string, object> 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<Dictionary<string, object>>(responseContent, SalesforceJsonOptions);
|
|
|
|
// Merge the original data with the creation result (which includes the new Id)
|
|
if (creationResult != null)
|
|
{
|
|
var result = new Dictionary<string, object>(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<Dictionary<string, object>?> UpsertEntityAsync(string entityName, Dictionary<string, object> 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;
|
|
}
|
|
} /// <summary>
|
|
/// Finds entities by their key fields in Salesforce.
|
|
/// Uses External ID GET when possible (single field), otherwise uses SOQL query.
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the SObject to search (e.g., "Account", "Contact").</param>
|
|
/// <param name="keyFields">The key fields and their values to match.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A list of matching entities or an empty list if none found.</returns>
|
|
public override async Task<List<Dictionary<string, object>>> FindEntitiesByKeysAsync(string entityName, Dictionary<string, object> 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<Dictionary<string, object>>();
|
|
}
|
|
|
|
// 🔠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<Dictionary<string, object>>(cancellationToken: cancellationToken);
|
|
if (entity != null)
|
|
{
|
|
_logger.LogDebug($"✅ Trovato tramite External ID GET: Id={entity.GetValueOrDefault("Id")}");
|
|
return new List<Dictionary<string, object>> { 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<string>();
|
|
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<string> { "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<Dictionary<string, object>>();
|
|
}
|
|
|
|
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
_logger.LogDebug($"SOQL Response: {responseContent}");
|
|
|
|
var queryResponse = JsonSerializer.Deserialize<SalesforceQueryResponse>(responseContent, SalesforceJsonOptions);
|
|
|
|
if (queryResponse?.Records != null && queryResponse.Records.Any())
|
|
{
|
|
var results = queryResponse.Records.Select(record =>
|
|
record as Dictionary<string, object> ?? new Dictionary<string, object>()
|
|
).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<Dictionary<string, object>>();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug($"⌠Errore durante la ricerca Salesforce: {ex.Message}");
|
|
_logger.LogDebug($"Stack Trace: {ex.StackTrace}");
|
|
return new List<Dictionary<string, object>>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes multiple queries using Salesforce Composite API for efficient data extraction
|
|
/// </summary>
|
|
/// <param name="queries">List of SOQL queries to execute</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>List of query results mapped by query index</returns>
|
|
public async Task<List<BatchQueryResult>> BatchExecuteQueriesAsync(List<string> queries, CancellationToken cancellationToken = default)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
_logger.LogDebug("Error: Not authenticated to Salesforce. Cannot perform batch queries.");
|
|
return new List<BatchQueryResult>();
|
|
}
|
|
|
|
if (!queries.Any())
|
|
{
|
|
return new List<BatchQueryResult>();
|
|
}
|
|
|
|
// Salesforce limit: max 25 operations per composite request
|
|
const int maxBatchSize = 25;
|
|
|
|
// Split into batches of 25
|
|
var batches = new List<(List<string> 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<BatchQueryResult>();
|
|
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<List<BatchQueryResult>> ExecuteQueryBatchAsync(List<string> 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<Dictionary<string, object>>()
|
|
}).ToList();
|
|
}
|
|
|
|
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
var compositeResponse = JsonSerializer.Deserialize<SalesforceCompositeResponse>(responseContent, SalesforceJsonOptions);
|
|
|
|
var results = new List<BatchQueryResult>();
|
|
|
|
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<SalesforceQueryResponse>(bodyElement.GetRawText(), SalesforceJsonOptions);
|
|
result.Records = queryResponse?.Records ?? new List<Dictionary<string, object>>();
|
|
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<Dictionary<string, object>>();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
result.ErrorMessage = subResponse.Body?.ToString() ?? "Unknown error";
|
|
result.Records = new List<Dictionary<string, object>>();
|
|
}
|
|
|
|
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<Dictionary<string, object>>()
|
|
}).ToList();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds multiple entities by different key fields using batch queries for improved performance
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the SObject to search</param>
|
|
/// <param name="keyFieldsList">List of key field combinations to search for</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Dictionary mapping original search index to found entities</returns>
|
|
public async Task<Dictionary<int, List<Dictionary<string, object>>>> BatchFindEntitiesByKeysAsync(string entityName, List<Dictionary<string, object>> 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<int, List<Dictionary<string, object>>>();
|
|
}
|
|
|
|
// Build queries for each key field combination
|
|
var queries = new List<string>();
|
|
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<int, List<Dictionary<string, object>>>();
|
|
}
|
|
|
|
// Execute batch queries
|
|
var batchResults = await BatchExecuteQueriesAsync(queries, cancellationToken);
|
|
|
|
// Map results back to original indices
|
|
var results = new Dictionary<int, List<Dictionary<string, object>>>();
|
|
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<int, List<Dictionary<string, object>>>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts multiple entities by their IDs using batch queries
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the SObject to retrieve</param>
|
|
/// <param name="entityIds">List of entity IDs to retrieve</param>
|
|
/// <param name="fieldsToSelect">Fields to select (if null, selects all fields)</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>List of retrieved entities</returns>
|
|
public async Task<List<Dictionary<string, object>>> BatchGetEntitiesByIdsAsync(string entityName, List<string> entityIds, List<string>? 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<Dictionary<string, object>>();
|
|
}
|
|
|
|
if (!entityIds.Any())
|
|
{
|
|
return new List<Dictionary<string, object>>();
|
|
}
|
|
|
|
// 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<string>();
|
|
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<Dictionary<string, object>>();
|
|
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<Dictionary<string, object>>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts all records from an entity using pagination and batch processing
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the SObject to extract</param>
|
|
/// <param name="fieldsToSelect">Specific fields to select (if null, uses common fields)</param>
|
|
/// <param name="whereClause">Optional WHERE clause for filtering</param>
|
|
/// <param name="maxRecords">Maximum number of records to retrieve (0 = no limit)</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>List of all extracted records</returns>
|
|
public async Task<List<Dictionary<string, object>>> ExtractAllEntitiesAsync(string entityName, List<string>? 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<Dictionary<string, object>>();
|
|
}
|
|
|
|
// 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<Dictionary<string, object>>();
|
|
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<SalesforceQueryResponse>($"{_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<Dictionary<string, object>>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts entities using multiple parallel queries with different criteria
|
|
/// Useful for extracting large datasets by splitting them by date ranges or other criteria
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the SObject to extract</param>
|
|
/// <param name="fieldsToSelect">Specific fields to select</param>
|
|
/// <param name="whereClauses">List of WHERE clauses for parallel extraction</param>
|
|
/// <param name="maxRecordsPerQuery">Maximum records per individual query</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>Combined list of all extracted records</returns>
|
|
public async Task<List<Dictionary<string, object>>> ExtractEntitiesParallelAsync(string entityName, List<string>? fieldsToSelect, List<string> 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<Dictionary<string, object>>();
|
|
}
|
|
|
|
// 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<Dictionary<string, object>>();
|
|
}
|
|
}).ToList();
|
|
|
|
// Wait for all parallel extractions to complete
|
|
var allResults = await Task.WhenAll(extractionTasks);
|
|
|
|
// Combine all results
|
|
var combinedRecords = new List<Dictionary<string, object>>();
|
|
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<Dictionary<string, object>>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper method to split large datasets into date-based chunks for parallel extraction
|
|
/// </summary>
|
|
/// <param name="startDate">Start date for extraction</param>
|
|
/// <param name="endDate">End date for extraction</param>
|
|
/// <param name="dateFieldName">Name of the date field to use for splitting (e.g., "CreatedDate", "LastModifiedDate")</param>
|
|
/// <param name="chunkSizeInDays">Size of each date chunk in days</param>
|
|
/// <returns>List of WHERE clauses for parallel extraction</returns>
|
|
public List<string> CreateDateBasedWhereClauses(DateTime startDate, DateTime endDate, string dateFieldName = "CreatedDate", int chunkSizeInDays = 30)
|
|
{
|
|
var whereClauses = new List<string>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// High-performance method to extract large datasets by automatically splitting them into optimal chunks
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the SObject to extract</param>
|
|
/// <param name="fieldsToSelect">Specific fields to select</param>
|
|
/// <param name="baseWhereClause">Base WHERE clause (optional)</param>
|
|
/// <param name="maxRecords">Maximum total records to extract (0 = no limit)</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>List of all extracted records</returns>
|
|
public async Task<List<Dictionary<string, object>>> ExtractLargeDatasetAsync(string entityName, List<string>? 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<Dictionary<string, object>>($"{_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<Dictionary<string, object>>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Optimized method to extract recently modified entities using batch operations
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the SObject to extract</param>
|
|
/// <param name="fieldsToSelect">Specific fields to select</param>
|
|
/// <param name="hoursBack">Number of hours back to look for modifications (default: 24)</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>List of recently modified entities</returns>
|
|
public async Task<List<Dictionary<string, object>>> ExtractRecentlyModifiedAsync(string entityName, List<string>? 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<Dictionary<string, object>>();
|
|
}
|
|
} /// <summary>
|
|
/// Deletes an entity in Salesforce by its ID.
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the SObject (e.g., "Account", "Contact").</param>
|
|
/// <param name="entityId">The ID of the entity to delete.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>True if deletion was successful, false otherwise.</returns>
|
|
public override async Task<bool> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates an existing entity in Salesforce by its ID.
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the SObject (e.g., "Account", "Contact").</param>
|
|
/// <param name="entityId">The ID of the entity to update.</param>
|
|
/// <param name="entityData">The data to update as key-value pairs.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The updated entity data or null if update failed.</returns>
|
|
public override async Task<Dictionary<string, object>?> UpdateEntityAsync(string entityName, string entityId, Dictionary<string, object> 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<string, object>(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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures the client is authenticated, attempting to authenticate if not already authenticated.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>True if authentication is successful, false otherwise.</returns>
|
|
private async Task<bool> EnsureAuthenticatedAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
if (IsAuthenticated())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
_logger.LogDebug("Client not authenticated, attempting to authenticate...");
|
|
return await AuthenticateAsync(cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizza i valori numerici in un dictionary per garantire compatibilità con Salesforce API
|
|
/// Converte valori decimali con virgola in formato con punto decimale
|
|
/// </summary>
|
|
private Dictionary<string, object> NormalizeNumericValues(Dictionary<string, object> data)
|
|
{
|
|
var normalizedData = new Dictionary<string, object>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifica se una stringa rappresenta un numero con virgola decimale
|
|
/// </summary>
|
|
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<SalesforceSObject> SObjects { get; set; } = new List<SalesforceSObject>();
|
|
}
|
|
|
|
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<SalesforceField> Fields { get; set; } = new List<SalesforceField>(); } /// <summary>
|
|
/// Finds entities by required fields to detect duplicates.
|
|
/// Now uses batch operations for improved performance when checking multiple field combinations.
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the entity to search.</param>
|
|
/// <param name="requiredFields">The required fields and their values to search for.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A list of matching entities.</returns>
|
|
public override async Task<List<Dictionary<string, object>>> FindEntitiesByRequiredFieldsAsync(string entityName, Dictionary<string, object> 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<Dictionary<string, object>>();
|
|
}
|
|
|
|
if (!requiredFields.Any())
|
|
{
|
|
_logger.LogDebug("No required fields provided for duplicate search");
|
|
return new List<Dictionary<string, object>>();
|
|
}
|
|
|
|
// Use the new batch search functionality for a single key field combination
|
|
var keyFieldsList = new List<Dictionary<string, object>> { 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<Dictionary<string, object>>();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug($"Error during required fields search: {ex.Message}");
|
|
return new List<Dictionary<string, object>>();
|
|
}
|
|
}
|
|
|
|
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<Dictionary<string, object>> Records { get; set; } = new List<Dictionary<string, object>>();
|
|
|
|
[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<Dictionary<string, object>> Records { get; set; } = new List<Dictionary<string, object>>();
|
|
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<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
|
|
{
|
|
[JsonPropertyName("compositeRequest")]
|
|
public List<SalesforceCompositeSubRequest> CompositeRequest { get; set; } = new List<SalesforceCompositeSubRequest>();
|
|
}
|
|
|
|
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<SalesforceCompositeSubResponse> CompositeResponse { get; set; } = new List<SalesforceCompositeSubResponse>();
|
|
}
|
|
|
|
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<string, object>? CreatedData { get; set; }
|
|
public Dictionary<string, object>? UpdatedData { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes multiple create operations using Salesforce Composite API with automatic batching
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the SObject to create</param>
|
|
/// <param name="entityDataList">List of entity data to create</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>List of results for each create operation</returns>
|
|
public async Task<List<CompositeOperationResult>> BatchCreateEntitiesAsync(string entityName, List<Dictionary<string, object>> entityDataList, CancellationToken cancellationToken = default)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
_logger.LogDebug("Error: Not authenticated to Salesforce. Cannot perform batch create.");
|
|
return new List<CompositeOperationResult>();
|
|
}
|
|
|
|
if (!entityDataList.Any())
|
|
{
|
|
return new List<CompositeOperationResult>();
|
|
}
|
|
|
|
// Salesforce limit: max 25 operations per composite request
|
|
const int maxBatchSize = 25;
|
|
|
|
// Split into batches of 25
|
|
var batches = new List<(List<Dictionary<string, object>> 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<CompositeOperationResult>();
|
|
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<List<CompositeOperationResult>> ExecuteCreateBatchAsync(string entityName, List<Dictionary<string, object>> 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<SalesforceCompositeResponse>(responseContent, SalesforceJsonOptions);
|
|
|
|
var results = new List<CompositeOperationResult>();
|
|
|
|
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<Dictionary<string, object>>(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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executes multiple update operations using Salesforce Composite API with automatic batching
|
|
/// </summary>
|
|
/// <param name="entityName">The name of the SObject to update</param>
|
|
/// <param name="updateData">Dictionary where key is entityId and value is the data to update</param>
|
|
/// <param name="cancellationToken">Cancellation token</param>
|
|
/// <returns>List of results for each update operation</returns>
|
|
public async Task<List<CompositeOperationResult>> BatchUpdateEntitiesAsync(string entityName, Dictionary<string, Dictionary<string, object>> updateData, CancellationToken cancellationToken = default)
|
|
{
|
|
if (!IsAuthenticated())
|
|
{
|
|
_logger.LogDebug("Error: Not authenticated to Salesforce. Cannot perform batch update.");
|
|
return new List<CompositeOperationResult>();
|
|
}
|
|
|
|
if (!updateData.Any())
|
|
{
|
|
return new List<CompositeOperationResult>();
|
|
}
|
|
|
|
// 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<string, Dictionary<string, object>> 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<CompositeOperationResult>();
|
|
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<List<CompositeOperationResult>> ExecuteUpdateBatchAsync(string entityName, Dictionary<string, Dictionary<string, object>> 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<SalesforceCompositeResponse>(responseContent, SalesforceJsonOptions);
|
|
|
|
var results = new List<CompositeOperationResult>();
|
|
|
|
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<string, object>(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();
|
|
}
|
|
}
|
|
}
|
|
}
|