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;
// Se Salesforce restituisce expires_in (es. client_credentials), usalo con un margine di 60s;
// altrimenti (password flow non restituisce expires_in) fallback al default 1h conservativo.
var ttlSeconds = tokenResponse.ExpiresIn.HasValue && tokenResponse.ExpiresIn.Value > 60
? tokenResponse.ExpiresIn.Value - 60
: 3600;
_tokenExpiry = DateTime.UtcNow.AddSeconds(ttlSeconds);
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
_logger.LogInformation("Salesforce authentication ({Flow}) successful. InstanceUrl={InstanceUrl}, TokenExpiry={Expiry} (TTL {Ttl}s, server expires_in={ExpiresIn})",
flowName, _instanceUrl, _tokenExpiry.ToLocalTime(), ttlSeconds, tokenResponse.ExpiresIn?.ToString() ?? "null");
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;
///
/// Durata di validità del token in secondi. Presente solo in alcuni flussi OAuth Salesforce
/// (es. client_credentials, JWT bearer); assente in altri (es. username/password) — in tal caso 0/null.
///
[JsonPropertyName("expires_in")]
public int? ExpiresIn { get; set; }
}
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();
}
}
///
/// Elimina N record SObject in batch tramite Salesforce Composite API.
/// Riduce N HTTP calls a ceil(N/25), eseguite in parallelo.
///
/// Nome SObject (es. "Account").
/// Lista di Id da eliminare.
/// Cancellation token.
/// Risultato per ogni Id (Success/ErrorMessage).
public async Task> BatchDeleteEntitiesAsync(
string entityName,
List entityIds,
CancellationToken cancellationToken = default)
{
if (!IsAuthenticated())
{
_logger.LogDebug("Error: Not authenticated to Salesforce. Cannot perform batch delete.");
return new List();
}
if (entityIds == null || entityIds.Count == 0)
return new List();
const int maxBatchSize = 25;
var batches = new List<(List batch, int startIndex, int batchNumber)>();
for (int i = 0; i < entityIds.Count; i += maxBatchSize)
{
var chunk = entityIds.Skip(i).Take(maxBatchSize).ToList();
batches.Add((chunk, i, (i / maxBatchSize) + 1));
}
_logger.LogDebug($"--- BatchDelete: {entityIds.Count} record in {batches.Count} batch (parallel) ---");
var batchTasks = batches.Select(async b =>
await ExecuteDeleteBatchAsync(entityName, b.batch, b.startIndex, cancellationToken));
var batchResults = await Task.WhenAll(batchTasks);
var allResults = new List();
foreach (var r in batchResults) allResults.AddRange(r);
_logger.LogDebug($"All delete batches completed: {allResults.Count(r => r.Success)} success, {allResults.Count(r => !r.Success)} failed");
return allResults;
}
private async Task> ExecuteDeleteBatchAsync(
string entityName,
List batch,
int startIndex,
CancellationToken cancellationToken)
{
try
{
var compositeUri = $"{_instanceUrl}/services/data/v60.0/composite/";
var compositeRequest = new SalesforceCompositeRequest();
for (int i = 0; i < batch.Count; i++)
{
var entityId = batch[i];
compositeRequest.CompositeRequest.Add(new SalesforceCompositeSubRequest
{
Method = "DELETE",
Url = $"/services/data/v60.0/sobjects/{entityName}/{entityId}",
ReferenceId = $"delete_{startIndex + i}"
// Body intenzionalmente null per DELETE
});
}
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 Delete failed: {response.StatusCode} - {errorContent}");
return batch.Select((id, idx) => new CompositeOperationResult
{
ReferenceId = $"delete_{startIndex + idx}",
EntityId = id,
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)
{
for (int i = 0; i < compositeResponse.CompositeResponse.Count; i++)
{
var sub = compositeResponse.CompositeResponse[i];
var originalId = i < batch.Count ? batch[i] : string.Empty;
var result = new CompositeOperationResult
{
ReferenceId = sub.ReferenceId,
EntityId = originalId,
HttpStatusCode = sub.HttpStatusCode,
// 204 No Content è la risposta di successo standard per DELETE
Success = sub.HttpStatusCode >= 200 && sub.HttpStatusCode < 300
};
if (!result.Success)
result.ErrorMessage = sub.Body?.ToString() ?? "Unknown error";
results.Add(result);
}
}
return results;
}
catch (Exception ex)
{
_logger.LogDebug($"Error during Salesforce batch delete: {ex.Message}");
return batch.Select((id, idx) => new CompositeOperationResult
{
ReferenceId = $"delete_{startIndex + idx}",
EntityId = id,
Success = false,
ErrorMessage = ex.Message
}).ToList();
}
}
///
/// Bulk PATCH per aggiornare un singolo campo (es. campo di tombstone/mark-as-deleted) su N record.
///
public Task> BatchPatchSingleFieldAsync(
string entityName,
IEnumerable entityIds,
string fieldName,
object fieldValue,
CancellationToken cancellationToken = default)
{
var updates = entityIds
.Where(id => !string.IsNullOrEmpty(id))
.ToDictionary(
id => id,
_ => (Dictionary)new Dictionary { { fieldName, fieldValue } });
return BatchUpdateEntitiesAsync(entityName, updates, cancellationToken);
}
}
}